diff options
Diffstat (limited to 'frontend/src/components/Sidebar.tsx')
| -rw-r--r-- | frontend/src/components/Sidebar.tsx | 689 |
1 files changed, 646 insertions, 43 deletions
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 3008ba3..a8dd82e 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,9 +1,9 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useMemo } from 'react'; import { useReactFlow } from 'reactflow'; import useFlowStore from '../store/flowStore'; -import type { NodeData, Trace, Message, MergedTrace, MergeStrategy } from '../store/flowStore'; +import type { NodeData, Trace, Message, MergedTrace, MergeStrategy, FileMeta } from '../store/flowStore'; import ReactMarkdown from 'react-markdown'; -import { Play, Settings, Info, Save, ChevronLeft, ChevronRight, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation } from 'lucide-react'; +import { Play, Settings, Info, Save, ChevronLeft, ChevronRight, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation, Upload, Search, Link } from 'lucide-react'; interface SidebarProps { isOpen: boolean; @@ -15,14 +15,21 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { const { nodes, edges, selectedNodeId, updateNodeData, getActiveContext, addNode, setSelectedNode, isTraceComplete, createQuickChatNode, theme, - createMergedTrace, updateMergedTrace, deleteMergedTrace, computeMergedMessages + createMergedTrace, updateMergedTrace, deleteMergedTrace, computeMergedMessages, + files, uploadFile, refreshFiles, addFileScope, removeFileScope, currentBlueprintPath, + saveCurrentBlueprint } = useFlowStore(); - const { setCenter } = useReactFlow(); + const { setCenter, getViewport } = useReactFlow(); const isDark = theme === 'dark'; const [activeTab, setActiveTab] = useState<'interact' | 'settings' | 'debug'>('interact'); const [streamBuffer, setStreamBuffer] = useState(''); const [streamingNodeId, setStreamingNodeId] = useState<string | null>(null); // Track which node is streaming + // Attachments state + const [showAttachModal, setShowAttachModal] = useState(false); + const [attachSearch, setAttachSearch] = useState(''); + const settingsUploadRef = useRef<HTMLInputElement>(null); + // Response Modal & Edit states const [isModalOpen, setIsModalOpen] = useState(false); const [isEditing, setIsEditing] = useState(false); @@ -45,8 +52,14 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { const [quickChatEffort, setQuickChatEffort] = useState<'low' | 'medium' | 'high'>('medium'); const [quickChatNeedsDuplicate, setQuickChatNeedsDuplicate] = useState(false); const [quickChatWebSearch, setQuickChatWebSearch] = useState(true); + const [quickChatAttachedFiles, setQuickChatAttachedFiles] = useState<string[]>([]); // File IDs for current message + const [quickChatSentFiles, setQuickChatSentFiles] = useState<{msgId: string, fileIds: string[]}[]>([]); // Files sent with messages + const [showQuickChatAttachModal, setShowQuickChatAttachModal] = useState(false); + const [quickChatAttachSearch, setQuickChatAttachSearch] = useState(''); + const [quickChatUploading, setQuickChatUploading] = useState(false); // Upload loading state const quickChatEndRef = useRef<HTMLDivElement>(null); const quickChatInputRef = useRef<HTMLTextAreaElement>(null); + const quickChatUploadRef = useRef<HTMLInputElement>(null); // Merge Trace states const [showMergeModal, setShowMergeModal] = useState(false); @@ -95,6 +108,86 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { } }, [quickChatMessages]); + // Attachment helpers + const handleAttach = async (fileId: string) => { + if (!selectedNode) return; + const current = selectedNode.data.attachedFileIds || []; + if (!current.includes(fileId)) { + updateNodeData(selectedNode.id, { + attachedFileIds: [...current, fileId] + }); + // Add scope to file for filtering + const projectPath = currentBlueprintPath || 'untitled'; + const scope = `${projectPath}/${selectedNode.id}`; + try { + await addFileScope(fileId, scope); + } catch (e) { + console.error('Failed to add file scope:', e); + } + // Auto-save blueprint to persist attached files + if (currentBlueprintPath) { + saveCurrentBlueprint(currentBlueprintPath, getViewport()).catch(console.error); + } + } + setShowAttachModal(false); + }; + + const handleDetach = async (fileId: string) => { + if (!selectedNode) return; + const current = selectedNode.data.attachedFileIds || []; + updateNodeData(selectedNode.id, { + attachedFileIds: current.filter(id => id !== fileId) + }); + // Remove scope from file + const projectPath = currentBlueprintPath || 'untitled'; + const scope = `${projectPath}/${selectedNode.id}`; + try { + await removeFileScope(fileId, scope); + } catch (e) { + console.error('Failed to remove file scope:', e); + } + // Auto-save blueprint to persist detached files + if (currentBlueprintPath) { + saveCurrentBlueprint(currentBlueprintPath, getViewport()).catch(console.error); + } + }; + + const handleUploadAndAttach = async (e: React.ChangeEvent<HTMLInputElement>) => { + if (!e.target.files || e.target.files.length === 0 || !selectedNode) return; + const file = e.target.files[0]; + try { + // Determine provider based on node model + const model = selectedNode.data.model; + let provider: 'local' | 'openai' | 'google' = 'local'; + if (model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3')) { + provider = 'openai'; + } else if (model.startsWith('gemini')) { + provider = 'google'; + } + + const meta = await uploadFile(file, { provider }); + handleAttach(meta.id); + } catch (err) { + alert(`Upload failed: ${(err as Error).message}`); + } finally { + e.target.value = ''; + } + }; + + // Filter files for attach modal + const filteredFilesToAttach = useMemo(() => { + const q = attachSearch.trim().toLowerCase(); + if (!q) return files; + return files.filter(f => f.name.toLowerCase().includes(q)); + }, [files, attachSearch]); + + // Filter files for Quick Chat attach modal + const filteredQuickChatFiles = useMemo(() => { + const q = quickChatAttachSearch.trim().toLowerCase(); + if (!q) return files; + return files.filter(f => f.name.toLowerCase().includes(q)); + }, [files, quickChatAttachSearch]); + if (!isOpen) { return ( <div className={`border-l h-screen flex flex-col items-center py-4 w-12 z-10 transition-all duration-300 ${ @@ -161,6 +254,44 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { // Use getActiveContext which respects the user's selected traces const context = getActiveContext(runningNodeId); + // Calculate scopes: all nodes in the current trace path + const projectPath = currentBlueprintPath || 'untitled'; + + // Compute all upstream node IDs by traversing edges backward + const traceNodeIds = new Set<string>(); + traceNodeIds.add(runningNodeId); + + const visited = new Set<string>(); + const queue = [runningNodeId]; + + while (queue.length > 0) { + const currentNodeId = queue.shift()!; + if (visited.has(currentNodeId)) continue; + visited.add(currentNodeId); + + // Find all incoming edges to this node + const incomingEdges = edges.filter(e => e.target === currentNodeId); + for (const edge of incomingEdges) { + const sourceNodeId = edge.source; + if (!visited.has(sourceNodeId)) { + traceNodeIds.add(sourceNodeId); + queue.push(sourceNodeId); + } + } + } + + // Build scopes for all nodes in the trace path + const scopes = Array.from(traceNodeIds).map(nodeId => `${projectPath}/${nodeId}`); + console.log('[file_search] trace scopes:', scopes); + + // If no prompt but has files, use a default prompt + const attachedFiles = selectedNode.data.attachedFileIds || []; + const effectivePrompt = runningPrompt?.trim() + ? runningPrompt + : attachedFiles.length > 0 + ? 'Please analyze the attached files.' + : ''; + try { const response = await fetch('http://localhost:8000/api/run_node_stream', { method: 'POST', @@ -168,7 +299,9 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { body: JSON.stringify({ node_id: runningNodeId, incoming_contexts: [{ messages: context }], - user_prompt: runningPrompt, + user_prompt: effectivePrompt, + attached_file_ids: attachedFiles, + scopes, merge_strategy: selectedNode.data.mergeStrategy || 'smart', config: { provider: selectedNode.data.model.includes('gpt') || selectedNode.data.model === 'o3' ? 'openai' : 'google', @@ -415,6 +548,34 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { const hasResponse = !!selectedNode.data.response; const hasDraftPrompt = !!selectedNode.data.userPrompt && !hasResponse; + // Helper to extract node ID from message ID (format: nodeId-u or nodeId-a) + const getNodeIdFromMsgId = (msgId: string): string | null => { + if (!msgId) return null; + const parts = msgId.split('-'); + if (parts.length >= 2) { + // Remove last part (-u or -a) and rejoin + return parts.slice(0, -1).join('-'); + } + return null; + }; + + // Helper to build sentFiles from messages + const buildSentFilesFromMessages = (messages: Message[]): {msgId: string, fileIds: string[]}[] => { + const sentFiles: {msgId: string, fileIds: string[]}[] = []; + for (const msg of messages) { + if (msg.role === 'user' && msg.id) { + const nodeId = getNodeIdFromMsgId(msg.id); + if (nodeId) { + const node = nodes.find(n => n.id === nodeId); + if (node && node.data.attachedFileIds && node.data.attachedFileIds.length > 0) { + sentFiles.push({ msgId: msg.id, fileIds: node.data.attachedFileIds }); + } + } + } + } + return sentFiles; + }; + if (isNewTrace || !trace) { // Start a new trace from current node const initialMessages: Message[] = []; @@ -433,6 +594,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { messages: initialMessages }); setQuickChatMessages(initialMessages); + setQuickChatSentFiles(buildSentFilesFromMessages(initialMessages)); setQuickChatNeedsDuplicate(false); setQuickChatLastNodeId(selectedNode.id); } else { @@ -456,6 +618,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { messages: fullMessages }); setQuickChatMessages(fullMessages); + setQuickChatSentFiles(buildSentFilesFromMessages(fullMessages)); // Set last node ID: if current node has response, start from here. // Otherwise start from trace source (which is the last completed node) @@ -471,6 +634,58 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { setQuickChatOpen(false); setQuickChatTrace(null); setQuickChatMessages([]); + setQuickChatAttachedFiles([]); + setQuickChatSentFiles([]); + }; + + // Quick Chat file attachment helpers + const getQuickChatScope = () => { + const projectPath = currentBlueprintPath || 'untitled'; + return `${projectPath}/quick_chat_temp`; + }; + + const handleQuickChatAttach = async (fileId: string) => { + if (!quickChatAttachedFiles.includes(fileId)) { + setQuickChatAttachedFiles(prev => [...prev, fileId]); + // Add scope to file for filtering + try { + await addFileScope(fileId, getQuickChatScope()); + } catch (e) { + console.error('Failed to add file scope:', e); + } + } + setShowQuickChatAttachModal(false); + }; + + const handleQuickChatDetach = async (fileId: string) => { + setQuickChatAttachedFiles(prev => prev.filter(id => id !== fileId)); + // Remove scope from file + try { + await removeFileScope(fileId, getQuickChatScope()); + } catch (e) { + console.error('Failed to remove file scope:', e); + } + }; + + const handleQuickChatUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { + if (!e.target.files || e.target.files.length === 0) return; + const file = e.target.files[0]; + setQuickChatUploading(true); + try { + const meta = await uploadFile(file, { provider: 'local' }); + setQuickChatAttachedFiles(prev => [...prev, meta.id]); + // Add scope to file for filtering + try { + await addFileScope(meta.id, getQuickChatScope()); + } catch (e) { + console.error('Failed to add file scope:', e); + } + } catch (err) { + alert(`Upload failed: ${(err as Error).message}`); + } finally { + e.target.value = ''; + setQuickChatUploading(false); + } }; // Open Quick Chat for a merged trace @@ -482,6 +697,16 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { const hasResponse = !!selectedNode.data.response; const hasDraftPrompt = !!selectedNode.data.userPrompt && !hasResponse; + // Helper to extract node ID from message ID (format: nodeId-u or nodeId-a) + const getNodeIdFromMsgId = (msgId: string): string | null => { + if (!msgId) return null; + const parts = msgId.split('-'); + if (parts.length >= 2) { + return parts.slice(0, -1).join('-'); + } + return null; + }; + // Build messages from merged trace const fullMessages: Message[] = [...merged.messages]; // Only include current node's content if it was sent @@ -492,6 +717,20 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { fullMessages.push({ id: `${selectedNode.id}-a`, role: 'assistant', content: selectedNode.data.response }); } + // Build sentFiles from messages + const sentFiles: {msgId: string, fileIds: string[]}[] = []; + for (const msg of fullMessages) { + if (msg.role === 'user' && msg.id) { + const nodeId = getNodeIdFromMsgId(msg.id); + if (nodeId) { + const node = nodes.find(n => n.id === nodeId); + if (node && node.data.attachedFileIds && node.data.attachedFileIds.length > 0) { + sentFiles.push({ msgId: msg.id, fileIds: node.data.attachedFileIds }); + } + } + } + } + // Create a pseudo-trace for the merged context setQuickChatTrace({ id: merged.id, @@ -500,6 +739,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { messages: fullMessages }); setQuickChatMessages(fullMessages); + setQuickChatSentFiles(sentFiles); setQuickChatNeedsDuplicate(false); // Merged traces don't duplicate setQuickChatOpen(true); @@ -718,19 +958,30 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { const activeTracesCheck = selectedNode ? checkActiveTracesComplete() : { complete: true }; const handleQuickChatSend = async () => { - if (!quickChatInput.trim() || !quickChatTrace || quickChatLoading || !selectedNode) return; + // Allow send if there's text OR attached files + const hasContent = quickChatInput.trim() || quickChatAttachedFiles.length > 0; + if (!hasContent || !quickChatTrace || quickChatLoading || !selectedNode) return; const userInput = quickChatInput; + const attachedFilesCopy = [...quickChatAttachedFiles]; + const msgId = `qc_${Date.now()}_u`; + const userMessage: Message = { - id: `qc_${Date.now()}_u`, + id: msgId, role: 'user', - content: userInput + content: userInput || '[Files attached]' }; + // Track sent files for display + if (attachedFilesCopy.length > 0) { + setQuickChatSentFiles(prev => [...prev, { msgId, fileIds: attachedFilesCopy }]); + } + // Add user message to display const messagesBeforeSend = [...quickChatMessages]; setQuickChatMessages(prev => [...prev, userMessage]); setQuickChatInput(''); + setQuickChatAttachedFiles([]); // Clear attached files after send setQuickChatLoading(true); // Store model at send time to avoid issues with model switching during streaming @@ -745,6 +996,10 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { const reasoningModels = ['gpt-5', 'gpt-5-chat-latest', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5-pro', 'gpt-5.1', 'gpt-5.1-chat-latest', 'o3']; const isReasoning = reasoningModels.includes(modelAtSend); + // Build scopes for file search (Quick Chat uses a temp scope) + const projectPath = currentBlueprintPath || 'untitled'; + const scopes = [`${projectPath}/quick_chat_temp`]; + // Call LLM API with current messages as context const response = await fetch('http://localhost:8000/api/run_node_stream', { method: 'POST', @@ -752,7 +1007,9 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { body: JSON.stringify({ node_id: 'quick_chat_temp', incoming_contexts: [{ messages: messagesBeforeSend }], - user_prompt: userInput, + user_prompt: userInput || 'Please analyze the attached files.', + attached_file_ids: attachedFilesCopy, + scopes, merge_strategy: 'smart', config: { provider: isOpenAI ? 'openai' : 'google', @@ -807,6 +1064,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { temperature: isReasoning ? 1 : tempAtSend, reasoningEffort: effortAtSend, enableGoogleSearch: webSearchAtSend, + attachedFileIds: attachedFilesCopy, status: 'success', querySentAt: Date.now(), responseReceivedAt: Date.now() @@ -850,6 +1108,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { forkedTraces: [], mergedTraces: [], activeTraceIds: [], + attachedFileIds: attachedFilesCopy, response: fullResponse, status: 'success' as const, inputs: 1, @@ -1575,6 +1834,81 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { {activeTab === 'settings' && ( <div className="space-y-4"> + {/* Attachments Section */} + <div className={`p-3 rounded border ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-gray-50 border-gray-200'}`}> + <label className={`block text-xs font-bold uppercase tracking-wider mb-2 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}> + Attached Files + </label> + + <div className="flex gap-2 mb-3"> + <button + onClick={() => settingsUploadRef.current?.click()} + className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 px-3 rounded text-xs font-medium transition-colors ${ + isDark ? 'bg-blue-600 hover:bg-blue-700 text-white' : 'bg-blue-600 hover:bg-blue-700 text-white' + }`} + > + <Upload size={14} /> + Upload & Attach + </button> + <input + ref={settingsUploadRef} + type="file" + className="hidden" + onChange={handleUploadAndAttach} + /> + + <button + onClick={() => { + refreshFiles(); + setShowAttachModal(true); + }} + className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 px-3 rounded text-xs font-medium border transition-colors ${ + isDark + ? 'border-gray-600 hover:bg-gray-700 text-gray-200' + : 'border-gray-300 hover:bg-gray-100 text-gray-700' + }`} + > + <Link size={14} /> + Attach Existing + </button> + </div> + + {(selectedNode.data.attachedFileIds || []).length === 0 ? ( + <p className={`text-xs text-center italic py-2 ${isDark ? 'text-gray-500' : 'text-gray-400'}`}> + No files attached. + </p> + ) : ( + <div className="space-y-1"> + {(selectedNode.data.attachedFileIds || []).map(id => { + const file = files.find(f => f.id === id); + if (!file) return null; + return ( + <div + key={id} + className={`group flex items-center justify-between p-2 rounded text-xs ${ + isDark ? 'bg-gray-700/50' : 'bg-white border border-gray-200' + }`} + > + <div className="flex items-center gap-2 overflow-hidden"> + <FileText size={14} className={isDark ? 'text-blue-400' : 'text-blue-500'} /> + <span className={`truncate ${isDark ? 'text-gray-200' : 'text-gray-700'}`}> + {file.name} + </span> + </div> + <button + onClick={() => handleDetach(id)} + className={`opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-red-500/20 text-red-500 transition-all`} + title="Remove attachment" + > + <X size={12} /> + </button> + </div> + ); + })} + </div> + )} + </div> + <div> <label className={`block text-sm font-medium mb-1 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>Merge Strategy</label> <select @@ -2082,7 +2416,11 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { <p>Start a conversation with this trace's context</p> </div> ) : ( - quickChatMessages.map((msg, idx) => ( + quickChatMessages.map((msg, idx) => { + // Find files sent with this message + const sentFilesForMsg = quickChatSentFiles.find(sf => sf.msgId === msg.id)?.fileIds || []; + + return ( <div key={msg.id || idx} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`} @@ -2097,36 +2435,55 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { /> )} - <div - className={`rounded-lg px-4 py-2 ${ - msg.role === 'user' - ? 'bg-blue-600 text-white' - : isDark - ? 'bg-gray-800 border border-gray-700 text-gray-200 shadow-sm' - : 'bg-white border border-gray-200 shadow-sm' - }`} - style={msg.sourceTraceColor ? { borderLeftColor: msg.sourceTraceColor, borderLeftWidth: '3px' } : undefined} - > - {/* Source trace label for user messages from merged trace */} - {msg.sourceTraceColor && msg.role === 'user' && ( - <div - className="text-[10px] opacity-70 mb-1 flex items-center gap-1" - > - <div - className="w-2 h-2 rounded-full" - style={{ backgroundColor: msg.sourceTraceColor }} - /> - <span>from trace #{msg.sourceTraceId?.slice(-4)}</span> + <div className={`flex flex-col gap-1 ${msg.role === 'user' ? 'items-end' : 'items-start'}`}> + {/* Files bubble (shown above text for user messages) */} + {msg.role === 'user' && sentFilesForMsg.length > 0 && ( + <div className="bg-blue-500 text-white rounded-lg px-3 py-2 text-sm w-fit max-w-full"> + <div className="flex flex-wrap gap-2"> + {sentFilesForMsg.map(fileId => { + const file = files.find(f => f.id === fileId); + return ( + <div key={fileId} className="flex items-center gap-1 bg-blue-600 rounded px-2 py-1 text-xs"> + <FileText size={12} /> + <span className="max-w-[120px] truncate">{file?.name || 'File'}</span> + </div> + ); + })} + </div> </div> )} - {msg.role === 'user' ? ( - <p className="whitespace-pre-wrap">{msg.content}</p> - ) : ( - <div className={`prose prose-sm max-w-none ${isDark ? 'prose-invert' : ''}`}> - <ReactMarkdown>{msg.content}</ReactMarkdown> - </div> - )} + <div + className={`rounded-lg px-4 py-2 ${ + msg.role === 'user' + ? 'bg-blue-600 text-white' + : isDark + ? 'bg-gray-800 border border-gray-700 text-gray-200 shadow-sm' + : 'bg-white border border-gray-200 shadow-sm' + }`} + style={msg.sourceTraceColor ? { borderLeftColor: msg.sourceTraceColor, borderLeftWidth: '3px' } : undefined} + > + {/* Source trace label for user messages from merged trace */} + {msg.sourceTraceColor && msg.role === 'user' && ( + <div + className="text-[10px] opacity-70 mb-1 flex items-center gap-1" + > + <div + className="w-2 h-2 rounded-full" + style={{ backgroundColor: msg.sourceTraceColor }} + /> + <span>from trace #{msg.sourceTraceId?.slice(-4)}</span> + </div> + )} + + {msg.role === 'user' ? ( + <p className="whitespace-pre-wrap">{msg.content}</p> + ) : ( + <div className={`prose prose-sm max-w-none ${isDark ? 'prose-invert' : ''}`}> + <ReactMarkdown>{msg.content}</ReactMarkdown> + </div> + )} + </div> </div> {/* Source trace indicator for user messages (on the right side) */} @@ -2139,7 +2496,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { )} </div> </div> - )) + )}) )} {quickChatLoading && ( <div className="flex justify-start"> @@ -2204,10 +2561,80 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { <span className={isDark ? 'text-gray-400' : 'text-gray-500'}>Web Search</span> </label> )} + + {/* File Attachment Buttons */} + <div className="flex items-center gap-1 ml-auto"> + <button + onClick={() => quickChatUploadRef.current?.click()} + disabled={quickChatUploading} + className={`px-2 py-0.5 rounded text-xs flex items-center gap-1 ${ + quickChatUploading + ? 'opacity-50 cursor-not-allowed' + : '' + } ${ + isDark + ? 'bg-gray-700 hover:bg-gray-600 text-gray-300' + : 'bg-gray-100 hover:bg-gray-200 text-gray-700' + }`} + title={quickChatUploading ? "Uploading..." : "Upload & Attach"} + > + {quickChatUploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />} + {quickChatUploading ? 'Uploading...' : 'Upload'} + </button> + <button + onClick={() => { + refreshFiles(); + setShowQuickChatAttachModal(true); + }} + className={`px-2 py-0.5 rounded text-xs flex items-center gap-1 ${ + isDark + ? 'bg-gray-700 hover:bg-gray-600 text-gray-300' + : 'bg-gray-100 hover:bg-gray-200 text-gray-700' + }`} + title="Attach Existing File" + > + <Link size={12} /> + Attach + </button> + <input + ref={quickChatUploadRef} + type="file" + className="hidden" + onChange={handleQuickChatUpload} + /> + </div> </div> {/* Input Area */} <div className={`p-4 border-t ${isDark ? 'border-gray-700 bg-gray-800' : 'border-gray-200 bg-white'}`}> + {/* Attached Files Preview */} + {quickChatAttachedFiles.length > 0 && ( + <div className={`mb-2 p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-100'}`}> + <div className="flex flex-wrap gap-2"> + {quickChatAttachedFiles.map(fileId => { + const file = files.find(f => f.id === fileId); + if (!file) return null; + return ( + <div + key={fileId} + className={`flex items-center gap-1 px-2 py-1 rounded text-xs ${ + isDark ? 'bg-gray-600 text-gray-200' : 'bg-white text-gray-700 border border-gray-300' + }`} + > + <FileText size={12} /> + <span className="max-w-[120px] truncate">{file.name}</span> + <button + onClick={() => handleQuickChatDetach(fileId)} + className={`ml-1 p-0.5 rounded hover:bg-red-500 hover:text-white ${isDark ? 'text-gray-400' : 'text-gray-500'}`} + > + <X size={10} /> + </button> + </div> + ); + })} + </div> + </div> + )} <div className="flex gap-2"> <textarea ref={quickChatInputRef} @@ -2216,15 +2643,17 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - // Only send if not loading - if (!quickChatLoading) { + // Only send if not loading and has content (text or files) + if (!quickChatLoading && (quickChatInput.trim() || quickChatAttachedFiles.length > 0)) { handleQuickChatSend(); } } }} placeholder={quickChatLoading ? "Waiting for response... (you can type here)" - : "Type your message... (Enter to send, Shift+Enter for new line)" + : quickChatAttachedFiles.length > 0 + ? "Add a message (optional) or just send the files..." + : "Type your message... (Enter to send, Shift+Enter for new line)" } className={`flex-1 border rounded-lg px-4 py-3 text-sm resize-y min-h-[50px] max-h-[150px] focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${ isDark ? 'bg-gray-700 border-gray-600 text-gray-200 placeholder-gray-400' : 'border-gray-300' @@ -2233,7 +2662,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { /> <button onClick={handleQuickChatSend} - disabled={!quickChatInput.trim() || quickChatLoading} + disabled={(!quickChatInput.trim() && quickChatAttachedFiles.length === 0) || quickChatLoading} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-blue-300 disabled:cursor-not-allowed flex items-center gap-2" > {quickChatLoading ? <Loader2 className="animate-spin" size={18} /> : <Send size={18} />} @@ -2246,6 +2675,180 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { </div> </div> )} + {/* Attach File Modal */} + {showAttachModal && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"> + <div + className={`w-full max-w-md rounded-lg shadow-xl flex flex-col max-h-[80vh] ${ + isDark ? 'bg-gray-800 border border-gray-700' : 'bg-white' + }`} + > + <div className={`p-4 border-b flex justify-between items-center ${isDark ? 'border-gray-700' : 'border-gray-200'}`}> + <h3 className={`font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>Attach File</h3> + <button + onClick={() => setShowAttachModal(false)} + className={`p-1 rounded ${isDark ? 'hover:bg-gray-700 text-gray-400' : 'hover:bg-gray-100 text-gray-500'}`} + > + <X size={20} /> + </button> + </div> + + <div className="p-4 border-b border-gray-200 dark:border-gray-700"> + <div className="relative"> + <Search size={16} className={`absolute left-3 top-1/2 -translate-y-1/2 ${isDark ? 'text-gray-500' : 'text-gray-400'}`} /> + <input + type="text" + placeholder="Search files..." + value={attachSearch} + onChange={(e) => setAttachSearch(e.target.value)} + className={`w-full pl-9 pr-3 py-2 rounded-md text-sm border outline-none focus:ring-2 focus:ring-blue-500 ${ + isDark + ? 'bg-gray-900 border-gray-600 text-white placeholder-gray-500' + : 'bg-white border-gray-300 text-gray-900 placeholder-gray-400' + }`} + autoFocus + /> + </div> + </div> + + <div className="flex-1 overflow-y-auto p-2"> + {filteredFilesToAttach.length === 0 ? ( + <div className={`text-center py-8 ${isDark ? 'text-gray-500' : 'text-gray-400'}`}> + <FileText size={48} className="mx-auto mb-2 opacity-50" /> + <p>No matching files found.</p> + </div> + ) : ( + <div className="space-y-1"> + {filteredFilesToAttach.map(file => { + const isAttached = (selectedNode?.data.attachedFileIds || []).includes(file.id); + return ( + <button + key={file.id} + onClick={() => handleAttach(file.id)} + disabled={isAttached} + className={`w-full flex items-center justify-between p-3 rounded text-left transition-colors ${ + isAttached + ? isDark ? 'opacity-50 cursor-not-allowed bg-gray-700/50' : 'opacity-50 cursor-not-allowed bg-gray-100' + : isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-50' + }`} + > + <div className="flex items-center gap-3 overflow-hidden"> + <div className={`p-2 rounded ${isDark ? 'bg-gray-700' : 'bg-gray-100'}`}> + <FileText size={18} className={isDark ? 'text-blue-400' : 'text-blue-600'} /> + </div> + <div className="min-w-0"> + <div className={`font-medium truncate ${isDark ? 'text-gray-200' : 'text-gray-700'}`}> + {file.name} + </div> + <div className={`text-xs truncate ${isDark ? 'text-gray-500' : 'text-gray-500'}`}> + {(file.size / 1024).toFixed(1)} KB • {new Date(file.created_at * 1000).toLocaleDateString()} + {file.provider && ` • ${file.provider}`} + </div> + </div> + </div> + {isAttached && ( + <Check size={16} className="text-green-500 flex-shrink-0" /> + )} + </button> + ); + })} + </div> + )} + </div> + + <div className={`p-4 border-t text-xs text-center ${isDark ? 'border-gray-700 text-gray-500' : 'border-gray-200 text-gray-500'}`}> + Showing {filteredFilesToAttach.length} file{filteredFilesToAttach.length !== 1 ? 's' : ''} + </div> + </div> + </div> + )} + + {/* Quick Chat Attach File Modal */} + {showQuickChatAttachModal && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"> + <div + className={`w-full max-w-md rounded-lg shadow-xl flex flex-col max-h-[80vh] ${ + isDark ? 'bg-gray-800 border border-gray-700' : 'bg-white' + }`} + > + <div className={`p-4 border-b flex justify-between items-center ${isDark ? 'border-gray-700' : 'border-gray-200'}`}> + <h3 className={`font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>Attach File to Quick Chat</h3> + <button + onClick={() => setShowQuickChatAttachModal(false)} + className={`p-1 rounded ${isDark ? 'hover:bg-gray-700 text-gray-400' : 'hover:bg-gray-100 text-gray-500'}`} + > + <X size={20} /> + </button> + </div> + + <div className="p-4 border-b border-gray-200 dark:border-gray-700"> + <div className="relative"> + <Search size={16} className={`absolute left-3 top-1/2 -translate-y-1/2 ${isDark ? 'text-gray-500' : 'text-gray-400'}`} /> + <input + type="text" + placeholder="Search files..." + value={quickChatAttachSearch} + onChange={(e) => setQuickChatAttachSearch(e.target.value)} + className={`w-full pl-9 pr-3 py-2 rounded-md text-sm border outline-none focus:ring-2 focus:ring-blue-500 ${ + isDark + ? 'bg-gray-900 border-gray-600 text-white placeholder-gray-500' + : 'bg-white border-gray-300 text-gray-900 placeholder-gray-400' + }`} + autoFocus + /> + </div> + </div> + + <div className="flex-1 overflow-y-auto p-2"> + {filteredQuickChatFiles.length === 0 ? ( + <div className={`text-center py-8 ${isDark ? 'text-gray-500' : 'text-gray-400'}`}> + <FileText size={48} className="mx-auto mb-2 opacity-50" /> + <p>No matching files found.</p> + </div> + ) : ( + <div className="space-y-1"> + {filteredQuickChatFiles.map(file => { + const isAttached = quickChatAttachedFiles.includes(file.id); + return ( + <button + key={file.id} + onClick={() => handleQuickChatAttach(file.id)} + disabled={isAttached} + className={`w-full flex items-center justify-between p-3 rounded text-left transition-colors ${ + isAttached + ? isDark ? 'opacity-50 cursor-not-allowed bg-gray-700/50' : 'opacity-50 cursor-not-allowed bg-gray-100' + : isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-50' + }`} + > + <div className="flex items-center gap-3 overflow-hidden"> + <div className={`p-2 rounded ${isDark ? 'bg-gray-700' : 'bg-gray-100'}`}> + <FileText size={18} className={isDark ? 'text-blue-400' : 'text-blue-600'} /> + </div> + <div className="min-w-0"> + <div className={`font-medium truncate ${isDark ? 'text-gray-200' : 'text-gray-700'}`}> + {file.name} + </div> + <div className={`text-xs truncate ${isDark ? 'text-gray-500' : 'text-gray-500'}`}> + {(file.size / 1024).toFixed(1)} KB • {new Date(file.created_at * 1000).toLocaleDateString()} + </div> + </div> + </div> + {isAttached && ( + <Check size={16} className="text-green-500 flex-shrink-0" /> + )} + </button> + ); + })} + </div> + )} + </div> + + <div className={`p-4 border-t text-xs text-center ${isDark ? 'border-gray-700 text-gray-500' : 'border-gray-200 text-gray-500'}`}> + Showing {filteredQuickChatFiles.length} file{filteredQuickChatFiles.length !== 1 ? 's' : ''} + </div> + </div> + </div> + )} </div> ); }; |
