From d9b17431a799a0354103ef390f6db15f34fb92be Mon Sep 17 00:00:00 2001 From: blackhao <13851610112@163.com> Date: Wed, 10 Dec 2025 19:30:26 -0600 Subject: init file sys --- frontend/src/App.tsx | 4 +- frontend/src/components/LeftSidebar.tsx | 58 ++- frontend/src/components/Sidebar.tsx | 689 ++++++++++++++++++++++++++++++-- frontend/src/store/flowStore.ts | 109 ++++- 4 files changed, 796 insertions(+), 64 deletions(-) (limited to 'frontend/src') diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8ae93c7..5477ff2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -158,7 +158,9 @@ function Flow() { mergedTraces: [], response: '', status: 'idle', - inputs: 1 + inputs: 1, + attachedFileIds: [], + activeTraceIds: [] }, }); setMenu(null); diff --git a/frontend/src/components/LeftSidebar.tsx b/frontend/src/components/LeftSidebar.tsx index a75df39..aff2df8 100644 --- a/frontend/src/components/LeftSidebar.tsx +++ b/frontend/src/components/LeftSidebar.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react' import { useReactFlow } from 'reactflow'; import { Folder, FileText, Archive, ChevronLeft, ChevronRight, Trash2, MessageSquare, - MoreVertical, Download, Upload, Plus, RefreshCw, Edit3 + MoreVertical, Download, Upload, Plus, RefreshCw, Edit3, Loader2 } from 'lucide-react'; import useFlowStore, { type FSItem, type BlueprintDocument, type FileMeta } from '../store/flowStore'; @@ -19,6 +19,7 @@ const LeftSidebar: React.FC = ({ isOpen, onToggle }) => { createNodeFromArchive, theme, files, + uploadingFileIds, projectTree, currentBlueprintPath, saveStatus, @@ -47,6 +48,9 @@ const LeftSidebar: React.FC = ({ isOpen, onToggle }) => { const [dragItem, setDragItem] = useState(null); const [showSaveStatus, setShowSaveStatus] = useState(false); const [expanded, setExpanded] = useState>(() => new Set(['.'])); + const [fileProvider, setFileProvider] = useState<'local' | 'openai' | 'google'>('local'); + const [openaiPurpose, setOpenaiPurpose] = useState('user_data'); + const [fileSearch, setFileSearch] = useState(''); const handleDragStart = (e: React.DragEvent, archiveId: string) => { e.dataTransfer.setData('archiveId', archiveId); @@ -235,7 +239,10 @@ const LeftSidebar: React.FC = ({ isOpen, onToggle }) => { let failed: string[] = []; for (const f of Array.from(list)) { try { - await uploadFile(f); + await uploadFile(f, { + provider: fileProvider, + purpose: fileProvider === 'openai' ? openaiPurpose : undefined, + }); ok += 1; } catch (e) { console.error(e); @@ -251,6 +258,13 @@ const LeftSidebar: React.FC = ({ isOpen, onToggle }) => { } }; + const filteredFiles = useMemo(() => { + const q = fileSearch.trim().toLowerCase(); + if (!q) return files; + // Only search local files; keep provider files out of filtered results + return files.filter(f => !f.provider && f.name.toLowerCase().includes(q)); + }, [files, fileSearch]); + const handleFilesInputChange = async (e: React.ChangeEvent) => { const files = e.target.files; if (files && files.length > 0) { @@ -610,14 +624,37 @@ const LeftSidebar: React.FC = ({ isOpen, onToggle }) => { Drag files here or click upload - {files.length === 0 ? ( +
+ setFileSearch(e.target.value)} + className={`flex-1 text-sm border rounded px-2 py-1 ${isDark ? 'bg-gray-800 border-gray-700 text-gray-100 placeholder-gray-500' : 'bg-white border-gray-200 text-gray-800 placeholder-gray-400'}`} + placeholder="Search files by name..." + /> + {fileSearch && ( + + )} +
+ + {files.length === 0 && (uploadingFileIds?.length || 0) === 0 ? (

No files uploaded yet.

+ ) : filteredFiles.length === 0 && (uploadingFileIds?.length || 0) === 0 ? ( +
+ +

No files match your search.

+
) : (
- {files.map(f => ( + {filteredFiles.map(f => (
= ({ isOpen, onToggle }) => { {formatSize(f.size)} • {new Date(f.created_at * 1000).toLocaleString()} + {f.provider && ( + + Provider: {f.provider === 'openai' ? 'OpenAI' : f.provider === 'google' ? 'Gemini' : f.provider} + + )}
))} + {uploadingFileIds && uploadingFileIds.length > 0 && ( +
+
+ + Uploading {uploadingFileIds.length} file{uploadingFileIds.length > 1 ? 's' : ''}… +
+
+ )} )} 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 = ({ 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(null); // Track which node is streaming + // Attachments state + const [showAttachModal, setShowAttachModal] = useState(false); + const [attachSearch, setAttachSearch] = useState(''); + const settingsUploadRef = useRef(null); + // Response Modal & Edit states const [isModalOpen, setIsModalOpen] = useState(false); const [isEditing, setIsEditing] = useState(false); @@ -45,8 +52,14 @@ const Sidebar: React.FC = ({ 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([]); // 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(null); const quickChatInputRef = useRef(null); + const quickChatUploadRef = useRef(null); // Merge Trace states const [showMergeModal, setShowMergeModal] = useState(false); @@ -95,6 +108,86 @@ const Sidebar: React.FC = ({ 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) => { + 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 (
= ({ 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(); + traceNodeIds.add(runningNodeId); + + const visited = new Set(); + 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 = ({ 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 = ({ 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 = ({ isOpen, onToggle, onInteract }) => { messages: initialMessages }); setQuickChatMessages(initialMessages); + setQuickChatSentFiles(buildSentFilesFromMessages(initialMessages)); setQuickChatNeedsDuplicate(false); setQuickChatLastNodeId(selectedNode.id); } else { @@ -456,6 +618,7 @@ const Sidebar: React.FC = ({ 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 = ({ 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) => { + 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ isOpen, onToggle, onInteract }) => { forkedTraces: [], mergedTraces: [], activeTraceIds: [], + attachedFileIds: attachedFilesCopy, response: fullResponse, status: 'success' as const, inputs: 1, @@ -1575,6 +1834,81 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => { {activeTab === 'settings' && (
+ {/* Attachments Section */} +
+ + +
+ + + + +
+ + {(selectedNode.data.attachedFileIds || []).length === 0 ? ( +

+ No files attached. +

+ ) : ( +
+ {(selectedNode.data.attachedFileIds || []).map(id => { + const file = files.find(f => f.id === id); + if (!file) return null; + return ( +
+
+ + + {file.name} + +
+ +
+ ); + })} +
+ )} +
+
+
{/* Input Area */}
+ {/* Attached Files Preview */} + {quickChatAttachedFiles.length > 0 && ( +
+
+ {quickChatAttachedFiles.map(fileId => { + const file = files.find(f => f.id === fileId); + if (!file) return null; + return ( +
+ + {file.name} + +
+ ); + })} +
+
+ )}