Spaces:
Sleeping
Sleeping
| import { useState } from 'react'; | |
| import { embedChunks, clearIndex, parseWithDocling } from '../api/client'; | |
| import { processSelectedFiles } from '../api/dropbox'; | |
| import { chunkFiles } from '../api/chunker'; | |
| import ProcessingStatus from './ProcessingStatus'; | |
| import IndexSummary from './IndexSummary'; | |
| import CloudConnect from './CloudConnect'; | |
| import DoclingOutput from './DoclingOutput'; | |
| export default function Sidebar({ onStatusChange, onAccessTokenChange }) { | |
| const [loading, setLoading] = useState(false); | |
| const [message, setMessage] = useState(null); | |
| const [processingState, setProcessingState] = useState(null); | |
| const [indexResult, setIndexResult] = useState(null); | |
| // New state for two-step flow | |
| const [stagedFiles, setStagedFiles] = useState([]); | |
| const [accessToken, setAccessToken] = useState(null); | |
| // State for Docling parsing output | |
| const [parsedDocuments, setParsedDocuments] = useState(null); | |
| const [pendingFileContents, setPendingFileContents] = useState(null); | |
| // Handle files staged from CloudConnect (not processed yet) | |
| const handleFilesStaged = (files) => { | |
| setStagedFiles(files); | |
| setMessage(null); | |
| setIndexResult(null); | |
| }; | |
| // Handle access token from CloudConnect - propagate to parent for queries | |
| const handleAccessTokenChange = (token) => { | |
| setAccessToken(token); | |
| onAccessTokenChange?.(token); // Propagate to App for QueryPanel | |
| if (!token) { | |
| setStagedFiles([]); | |
| } | |
| }; | |
| // Remove a single file from staged list | |
| const removeFile = (fileId) => { | |
| setStagedFiles(stagedFiles.filter(f => f.id !== fileId)); | |
| }; | |
| // Clear all staged files | |
| const clearStagedFiles = () => { | |
| setStagedFiles([]); | |
| }; | |
| // Start indexing the staged files - Phase 1: Read and Parse | |
| const handleIndexFiles = async () => { | |
| if (stagedFiles.length === 0 || !accessToken) return; | |
| setLoading(true); | |
| setMessage(null); | |
| setIndexResult(null); | |
| try { | |
| // Step 1: Read files from Dropbox | |
| setProcessingState({ step: 'read', fileName: `${stagedFiles.length} files`, progress: 10 }); | |
| const fileContents = await processSelectedFiles(stagedFiles, accessToken, (progress) => { | |
| setProcessingState({ | |
| step: 'read', | |
| fileName: progress.fileName, | |
| progress: 10 + (progress.current / progress.total) * 15, | |
| }); | |
| }); | |
| if (fileContents.length === 0) { | |
| setMessage({ type: 'error', text: 'No readable files found' }); | |
| setLoading(false); | |
| setProcessingState(null); | |
| return; | |
| } | |
| // Step 2: Parse with Docling | |
| setProcessingState({ step: 'parse', fileName: `${fileContents.length} files`, progress: 28 }); | |
| const parseResult = await parseWithDocling( | |
| stagedFiles.map(f => ({ path: f.path_lower, name: f.name })), | |
| accessToken | |
| ); | |
| if (parseResult.error) { | |
| setMessage({ type: 'error', text: parseResult.error }); | |
| setLoading(false); | |
| setProcessingState(null); | |
| return; | |
| } | |
| // Store results and pause for user review | |
| setParsedDocuments(parseResult.results); | |
| setPendingFileContents(fileContents); | |
| setProcessingState(null); | |
| setLoading(false); | |
| // User will click "Continue to Indexing" in DoclingOutput | |
| } catch (err) { | |
| setMessage({ type: 'error', text: err.message }); | |
| setProcessingState(null); | |
| setLoading(false); | |
| } | |
| }; | |
| // Continue indexing after user reviews Docling output | |
| const handleContinueIndexing = async () => { | |
| if (!pendingFileContents) return; | |
| setLoading(true); | |
| setParsedDocuments(null); | |
| try { | |
| const fileContents = pendingFileContents; | |
| setPendingFileContents(null); | |
| // Step 3: Chunk files (client-side) | |
| setProcessingState({ step: 'chunk', fileName: `${fileContents.length} files`, progress: 40 }); | |
| await new Promise(r => setTimeout(r, 100)); | |
| const chunks = chunkFiles(fileContents); | |
| // Step 4: Clear existing index | |
| setProcessingState({ step: 'clear', fileName: 'Clearing old data', progress: 50 }); | |
| await clearIndex(); | |
| // Step 5: Send chunks to server for embedding | |
| setProcessingState({ step: 'embed', fileName: `${chunks.length} chunks`, progress: 65 }); | |
| const result = await embedChunks(chunks); | |
| // Step 6: Show discard step | |
| setProcessingState({ step: 'discard', fileName: '', progress: 85 }); | |
| await new Promise(r => setTimeout(r, 300)); | |
| // Step 7: Complete | |
| setProcessingState({ step: 'save', fileName: '', progress: 100 }); | |
| await new Promise(r => setTimeout(r, 200)); | |
| if (result.status === 'success') { | |
| setIndexResult({ | |
| files: fileContents.length, | |
| chunks: result.vectors_upserted, | |
| }); | |
| setStagedFiles([]); // Clear staged files after successful indexing | |
| onStatusChange?.(); | |
| } else { | |
| setMessage({ type: 'error', text: result.error || 'Embedding failed' }); | |
| } | |
| } catch (err) { | |
| setMessage({ type: 'error', text: err.message }); | |
| } | |
| setProcessingState(null); | |
| setLoading(false); | |
| }; | |
| // Format file size | |
| const formatSize = (bytes) => { | |
| if (bytes < 1024) return `${bytes} B`; | |
| if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; | |
| return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; | |
| }; | |
| return ( | |
| <div className="w-72 bg-slate-800 p-4 border-r border-slate-700 flex flex-col h-full overflow-auto"> | |
| <h2 className="text-base font-semibold mb-4 text-slate-100">Document Source</h2> | |
| {/* Processing Status */} | |
| {processingState && ( | |
| <div className="mb-4"> | |
| <ProcessingStatus | |
| currentStep={processingState.step} | |
| fileName={processingState.fileName} | |
| progress={processingState.progress} | |
| /> | |
| </div> | |
| )} | |
| {/* Index Result - dismissible */} | |
| {indexResult && !processingState && ( | |
| <div className="mb-4"> | |
| <IndexSummary | |
| files={indexResult.files} | |
| chunks={indexResult.chunks} | |
| onClose={() => setIndexResult(null)} | |
| /> | |
| </div> | |
| )} | |
| {/* Cloud Storage - always visible when not processing */} | |
| {!processingState && ( | |
| <div className="mb-4"> | |
| <CloudConnect | |
| onFilesStaged={handleFilesStaged} | |
| stagedFiles={stagedFiles} | |
| onAccessTokenChange={handleAccessTokenChange} | |
| /> | |
| </div> | |
| )} | |
| {/* Staged Files List */} | |
| {!processingState && stagedFiles.length > 0 && ( | |
| <div className="mb-4"> | |
| <div className="flex items-center justify-between mb-2"> | |
| <h3 className="text-sm font-medium text-slate-300"> | |
| Selected Files ({stagedFiles.length}) | |
| </h3> | |
| <button | |
| type="button" | |
| onClick={clearStagedFiles} | |
| className="text-xs text-slate-400 hover:text-red-400 transition-colors" | |
| > | |
| Clear All | |
| </button> | |
| </div> | |
| <div className="bg-slate-900 border border-slate-700 rounded-lg divide-y divide-slate-700 max-h-48 overflow-auto"> | |
| {stagedFiles.map(file => ( | |
| <div key={file.id} className="flex items-center justify-between p-2.5 hover:bg-slate-800 group"> | |
| <div className="flex items-center gap-2 min-w-0 flex-1"> | |
| <svg className="w-4 h-4 text-slate-500 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24"> | |
| <path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4z"/> | |
| </svg> | |
| <div className="min-w-0"> | |
| <p className="text-sm text-slate-200 truncate">{file.name}</p> | |
| <p className="text-xs text-slate-500">{formatSize(file.size)}</p> | |
| </div> | |
| </div> | |
| <button | |
| type="button" | |
| onClick={() => removeFile(file.id)} | |
| className="p-1 text-slate-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all" | |
| aria-label={`Remove ${file.name}`} | |
| > | |
| <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> | |
| </svg> | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| {/* Index Button */} | |
| <button | |
| type="button" | |
| onClick={handleIndexFiles} | |
| disabled={loading || stagedFiles.length === 0} | |
| className="w-full mt-3 flex items-center justify-center gap-2 bg-blue-600 text-white rounded-lg px-4 py-2.5 text-sm font-medium hover:bg-blue-700 active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed shadow-sm transition-all duration-200" | |
| > | |
| {loading ? ( | |
| <> | |
| <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div> | |
| Indexing... | |
| </> | |
| ) : ( | |
| <> | |
| <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /> | |
| </svg> | |
| Index Selected Files | |
| </> | |
| )} | |
| </button> | |
| </div> | |
| )} | |
| {/* Error/Success Messages */} | |
| {message && ( | |
| <div | |
| className={`p-3 rounded-lg text-sm mb-4 ${ | |
| message.type === 'success' | |
| ? 'bg-green-900/30 border border-green-700 text-green-400' | |
| : 'bg-red-900/30 border border-red-700 text-red-400' | |
| }`} | |
| role="alert" | |
| > | |
| {message.text} | |
| </div> | |
| )} | |
| {/* Docling Output Modal */} | |
| {parsedDocuments && ( | |
| <DoclingOutput | |
| results={parsedDocuments} | |
| onContinue={handleContinueIndexing} | |
| onDownload={() => {}} | |
| /> | |
| )} | |
| </div> | |
| ); | |
| } | |