Spaces:
Running
Running
| {# | |
| Knowledge Graph Editor - React Flow based node editor | |
| For linking PDF pages to questions with revision notes | |
| #} | |
| <html lang="en" data-bs-theme="dark"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Knowledge Graph - DocuPDF</title> | |
| <!-- Bootstrap --> | |
| <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"> | |
| <!-- React Flow --> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reactflow@11.10.1/dist/style.css" /> | |
| <style> | |
| :root { | |
| --bg-dark: #212529; | |
| --bg-card: #2b3035; | |
| --bg-elevated: #343a40; | |
| --border-subtle: #495057; | |
| --text-primary: #e9ecef; | |
| --text-muted: #adb5bd; | |
| --accent-primary: #0d6efd; | |
| --accent-info: #0dcaf0; | |
| --accent-success: #198754; | |
| --accent-warning: #ffc107; | |
| --accent-danger: #dc3545; | |
| } | |
| body { | |
| margin: 0; | |
| padding: 0; | |
| overflow: hidden; | |
| background: var(--bg-dark); | |
| } | |
| .graph-container { | |
| display: flex; | |
| flex-direction: column; | |
| height: 100vh; | |
| } | |
| .graph-toolbar { | |
| background: var(--bg-card); | |
| border-bottom: 1px solid var(--border-subtle); | |
| padding: 0.75rem 1rem; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| z-index: 10; | |
| } | |
| .graph-canvas { | |
| flex: 1; | |
| position: relative; | |
| } | |
| .react-flow__node { | |
| font-size: 12px; | |
| transition: all 0.2s ease; | |
| } | |
| .react-flow__node:hover { | |
| transform: scale(1.02); | |
| } | |
| /* Custom Node Styles */ | |
| .node-question { | |
| background: linear-gradient(135deg, #0d6efd 0%, #0a58ca 100%); | |
| border: 2px solid #6ea8fe; | |
| border-radius: 8px; | |
| padding: 12px; | |
| min-width: 200px; | |
| color: white; | |
| box-shadow: 0 4px 12px rgba(13, 110, 253, 0.3); | |
| } | |
| .node-note { | |
| background: linear-gradient(135deg, #198754 0%, #146c43 100%); | |
| border: 2px solid #75b798; | |
| border-radius: 8px; | |
| padding: 12px; | |
| min-width: 180px; | |
| color: white; | |
| box-shadow: 0 4px 12px rgba(25, 135, 84, 0.3); | |
| } | |
| .node-page { | |
| background: linear-gradient(135deg, #6f42c1 0%, #59359a 100%); | |
| border: 2px solid #b19cd9; | |
| border-radius: 8px; | |
| padding: 12px; | |
| min-width: 150px; | |
| color: white; | |
| box-shadow: 0 4px 12px rgba(111, 66, 193, 0.3); | |
| } | |
| .node-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 8px; | |
| font-weight: 600; | |
| font-size: 13px; | |
| } | |
| .node-content { | |
| font-size: 11px; | |
| opacity: 0.9; | |
| line-height: 1.4; | |
| } | |
| .node-actions { | |
| display: flex; | |
| gap: 4px; | |
| margin-top: 8px; | |
| justify-content: flex-end; | |
| } | |
| .node-btn { | |
| background: rgba(255, 255, 255, 0.2); | |
| border: none; | |
| color: white; | |
| padding: 4px 8px; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| font-size: 10px; | |
| transition: all 0.15s ease; | |
| } | |
| .node-btn:hover { | |
| background: rgba(255, 255, 255, 0.3); | |
| } | |
| .node-btn.delete:hover { | |
| background: rgba(220, 53, 69, 0.8); | |
| } | |
| /* Side Panel */ | |
| .side-panel { | |
| position: absolute; | |
| top: 0; | |
| right: 0; | |
| width: 350px; | |
| height: 100%; | |
| background: var(--bg-card); | |
| border-left: 1px solid var(--border-subtle); | |
| z-index: 5; | |
| transform: translateX(100%); | |
| transition: transform 0.3s ease; | |
| overflow-y: auto; | |
| } | |
| .side-panel.open { | |
| transform: translateX(0); | |
| } | |
| .side-panel-header { | |
| padding: 1rem; | |
| border-bottom: 1px solid var(--border-subtle); | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| background: var(--bg-elevated); | |
| } | |
| .side-panel-body { | |
| padding: 1rem; | |
| } | |
| /* Mini Map Customization */ | |
| .react-flow__minimap { | |
| background: var(--bg-elevated) ; | |
| } | |
| .react-flow__minimap-mask { | |
| fill: rgba(0, 0, 0, 0.3) ; | |
| } | |
| /* Controls */ | |
| .react-flow__controls { | |
| background: var(--bg-card) ; | |
| border: 1px solid var(--border-subtle) ; | |
| } | |
| .react-flow__controls-button { | |
| background: var(--bg-elevated) ; | |
| border-color: var(--border-subtle) ; | |
| } | |
| .react-flow__controls-button:hover { | |
| background: var(--bg-hover) ; | |
| } | |
| /* Upload Overlay */ | |
| .upload-overlay { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background: rgba(33, 37, 41, 0.95); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 100; | |
| } | |
| .upload-card { | |
| background: var(--bg-card); | |
| border: 2px dashed var(--border-subtle); | |
| border-radius: 16px; | |
| padding: 3rem; | |
| text-align: center; | |
| max-width: 500px; | |
| } | |
| .upload-card.drag-over { | |
| border-color: var(--accent-primary); | |
| background: rgba(13, 110, 253, 0.1); | |
| } | |
| /* Connection Line */ | |
| .react-flow__edge-path { | |
| stroke: var(--accent-info); | |
| stroke-width: 2px; | |
| } | |
| .react-flow__edge.selected .react-flow__edge-path { | |
| stroke: var(--accent-warning); | |
| stroke-width: 3px; | |
| } | |
| /* Loading Spinner */ | |
| .loading-spinner { | |
| display: inline-block; | |
| width: 2rem; | |
| height: 2rem; | |
| border: 3px solid rgba(255, 255, 255, 0.3); | |
| border-radius: 50%; | |
| border-top-color: white; | |
| animation: spin 1s linear infinite; | |
| } | |
| @keyframes spin { | |
| to { transform: rotate(360deg); } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="graph-app"></div> | |
| <!-- React & ReactDOM --> | |
| <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script> | |
| <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script> | |
| <!-- React Flow --> | |
| <script src="https://cdn.jsdelivr.net/npm/reactflow@11.10.1/dist/index.js"></script> | |
| <!-- Babel for JSX --> | |
| <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> | |
| <script type="text/babel"> | |
| const { useState, useCallback, useMemo, useRef, useEffect } = React; | |
| const ReactFlow = window.ReactFlow; | |
| const addEdge = window.ReactFlow.addEdge; | |
| const useNodesState = window.ReactFlow.useNodesState; | |
| const useEdgesState = window.ReactFlow.useEdgesState; | |
| const useReactFlow = window.ReactFlow.useReactFlow; | |
| const Controls = window.ReactFlow.Controls; | |
| const MiniMap = window.ReactFlow.MiniMap; | |
| const Background = window.ReactFlow.Background; | |
| // === Custom Node Components === | |
| const QuestionNode = ({ id, data }) => { | |
| const { setNodes, setEdges } = useReactFlow(); | |
| const handleDelete = () => { | |
| setNodes(nodes => nodes.filter(n => n.id !== id)); | |
| setEdges(edges => edges.filter(e => e.source !== id && e.target !== id)); | |
| }; | |
| return ( | |
| <div className="node-question"> | |
| <div className="node-header"> | |
| <i className="bi bi-question-circle"></i> | |
| <span>Question {data.number}</span> | |
| </div> | |
| <div className="node-content"> | |
| {data.subject && <div><i className="bi bi-book"></i> {data.subject}</div>} | |
| {data.chapter && <div><i className="bi bi-folder"></i> {data.chapter}</div>} | |
| </div> | |
| <div className="node-actions"> | |
| <button className="node-btn" onClick={() => alert('Edit question: ' + data.number)}> | |
| <i className="bi bi-pencil"></i> | |
| </button> | |
| <button className="node-btn delete" onClick={handleDelete}> | |
| <i className="bi bi-trash"></i> | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| const NoteNode = ({ id, data }) => { | |
| const { setNodes, setEdges } = useReactFlow(); | |
| const handleDelete = () => { | |
| setNodes(nodes => nodes.filter(n => n.id !== id)); | |
| setEdges(edges => edges.filter(e => e.source !== id && e.target !== id)); | |
| }; | |
| return ( | |
| <div className="node-note"> | |
| <div className="node-header"> | |
| <i className="bi bi-sticky"></i> | |
| <span>Revision Note</span> | |
| </div> | |
| <div className="node-content"> | |
| {data.content && <div>{data.content.substring(0, 80)}...</div>} | |
| {data.tags && <div><i className="bi bi-tags"></i> {data.tags.join(', ')}</div>} | |
| </div> | |
| <div className="node-actions"> | |
| <button className="node-btn" onClick={() => alert('Edit note')}> | |
| <i className="bi bi-pencil"></i> | |
| </button> | |
| <button className="node-btn delete" onClick={handleDelete}> | |
| <i className="bi bi-trash"></i> | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| const PageNode = ({ id, data }) => { | |
| const { setNodes, setEdges } = useReactFlow(); | |
| const handleDelete = () => { | |
| setNodes(nodes => nodes.filter(n => n.id !== id)); | |
| setEdges(edges => edges.filter(e => e.source !== id && e.target !== id)); | |
| }; | |
| const handleView = () => { | |
| window.open(data.imageUrl, '_blank'); | |
| }; | |
| return ( | |
| <div className="node-page"> | |
| <div className="node-header"> | |
| <i className="bi bi-file-earmark-image"></i> | |
| <span>Page {data.pageNumber}</span> | |
| </div> | |
| <div className="node-content"> | |
| {data.imageUrl && ( | |
| <img src={data.imageUrl} alt={`Page ${data.pageNumber}`} style={{width: '100%', borderRadius: '4px', marginTop: '8px'}} /> | |
| )} | |
| </div> | |
| <div className="node-actions"> | |
| <button className="node-btn" onClick={handleView}> | |
| <i className="bi bi-eye"></i> | |
| </button> | |
| <button className="node-btn delete" onClick={handleDelete}> | |
| <i className="bi bi-trash"></i> | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| // === Node Types Registry === | |
| const nodeTypes = { | |
| question: QuestionNode, | |
| note: NoteNode, | |
| page: PageNode | |
| }; | |
| // === Main Graph App === | |
| const GraphApp = () => { | |
| const [nodes, setNodes, onNodesChange] = useNodesState([]); | |
| const [edges, setEdges, onEdgesChange] = useEdgesState([]); | |
| const [selectedNode, setSelectedNode] = useState(null); | |
| const [showPanel, setShowPanel] = useState(false); | |
| const [pdfFile, setPdfFile] = useState(null); | |
| const [pdfPages, setPdfPages] = useState([]); | |
| const [sessionId, setSessionId] = useState(null); | |
| const reactFlowWrapper = useRef(null); | |
| // Initial nodes | |
| useEffect(() => { | |
| // Load initial data if available | |
| const initialNodes = [ | |
| { | |
| id: 'note-1', | |
| type: 'note', | |
| position: { x: 100, y: 100 }, | |
| data: { | |
| content: 'Welcome to Knowledge Graph! Start by uploading a PDF.', | |
| tags: ['welcome'] | |
| } | |
| } | |
| ]; | |
| setNodes(initialNodes); | |
| }, []); | |
| // Handle node click | |
| const onNodeClick = useCallback((event, node) => { | |
| setSelectedNode(node); | |
| setShowPanel(true); | |
| }, []); | |
| // Handle edge connect | |
| const onConnect = useCallback((params) => { | |
| setEdges(eds => addEdge({ | |
| ...params, | |
| type: 'smoothstep', | |
| animated: true, | |
| style: { stroke: '#0dcaf0', strokeWidth: 2 } | |
| }, eds)); | |
| }, [setEdges]); | |
| // Add question node | |
| const addQuestionNode = (questionData) => { | |
| const newNode = { | |
| id: `question-${Date.now()}`, | |
| type: 'question', | |
| position: { | |
| x: Math.random() * 400 + 100, | |
| y: Math.random() * 300 + 100 | |
| }, | |
| data: { | |
| number: questionData.number || '?', | |
| subject: questionData.subject || '', | |
| chapter: questionData.chapter || '' | |
| } | |
| }; | |
| setNodes(nds => [...nds, newNode]); | |
| }; | |
| // Add note node | |
| const addNoteNode = (noteData) => { | |
| const newNode = { | |
| id: `note-${Date.now()}`, | |
| type: 'note', | |
| position: { | |
| x: Math.random() * 400 + 100, | |
| y: Math.random() * 300 + 100 | |
| }, | |
| data: { | |
| content: noteData.content || 'New note', | |
| tags: noteData.tags || [] | |
| } | |
| }; | |
| setNodes(nds => [...nds, newNode]); | |
| }; | |
| // Add page node | |
| const addPageNode = (pageData) => { | |
| const newNode = { | |
| id: `page-${Date.now()}`, | |
| type: 'page', | |
| position: { | |
| x: Math.random() * 400 + 100, | |
| y: Math.random() * 300 + 100 | |
| }, | |
| data: { | |
| pageNumber: pageData.pageNumber, | |
| imageUrl: pageData.imageUrl | |
| } | |
| }; | |
| setNodes(nds => [...nds, newNode]); | |
| }; | |
| // Handle PDF upload | |
| const handlePdfUpload = async (file) => { | |
| const formData = new FormData(); | |
| formData.append('pdf', file); | |
| try { | |
| const response = await fetch('/knowledge_graph/upload_pdf', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| const result = await response.json(); | |
| if (result.success) { | |
| setSessionId(result.session_id); | |
| setPdfFile(file); | |
| // Create page nodes for each page | |
| const pageNodes = result.pages.map((page, index) => ({ | |
| id: `page-${index}`, | |
| type: 'page', | |
| position: { x: 50 + (index % 5) * 200, y: 50 + Math.floor(index / 5) * 250 }, | |
| data: { | |
| pageNumber: index + 1, | |
| imageUrl: page.image_url | |
| } | |
| })); | |
| setPdfPages(result.pages); | |
| setNodes(pageNodes); | |
| } | |
| } catch (error) { | |
| console.error('Error uploading PDF:', error); | |
| alert('Failed to upload PDF'); | |
| } | |
| }; | |
| // Save graph | |
| const saveGraph = async () => { | |
| try { | |
| const response = await fetch('/knowledge_graph/save', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| session_id: sessionId, | |
| nodes: nodes, | |
| edges: edges | |
| }) | |
| }); | |
| const result = await response.json(); | |
| if (result.success) { | |
| alert('Graph saved successfully!'); | |
| } | |
| } catch (error) { | |
| console.error('Error saving graph:', error); | |
| alert('Failed to save graph'); | |
| } | |
| }; | |
| // Export graph as image | |
| const exportGraph = async () => { | |
| alert('Export functionality coming soon!'); | |
| }; | |
| // If no PDF uploaded, show upload overlay | |
| if (!sessionId) { | |
| return ( | |
| <div className="graph-container"> | |
| <div className="upload-overlay"> | |
| <div className="upload-card" | |
| onDragOver={(e) => { e.preventDefault(); e.currentTarget.classList.add('drag-over'); }} | |
| onDragLeave={(e) => { e.currentTarget.classList.remove('drag-over'); }} | |
| onDrop={(e) => { | |
| e.preventDefault(); | |
| e.currentTarget.classList.remove('drag-over'); | |
| const file = e.dataTransfer.files[0]; | |
| if (file && file.type === 'application/pdf') { | |
| handlePdfUpload(file); | |
| } | |
| }} | |
| > | |
| <i className="bi bi-file-earmark-pdf display-1 text-danger mb-3"></i> | |
| <h3>Upload PDF to Start</h3> | |
| <p className="text-muted mb-4"> | |
| Drag & drop a PDF file here, or click to browse | |
| </p> | |
| <input | |
| type="file" | |
| accept=".pdf" | |
| onChange={(e) => handlePdfUpload(e.target.files[0])} | |
| style={{ display: 'none' }} | |
| id="pdf-upload-input" | |
| /> | |
| <button | |
| className="btn btn-primary btn-lg" | |
| onClick={() => document.getElementById('pdf-upload-input').click()} | |
| > | |
| <i className="bi bi-folder2-open me-2"></i>Choose PDF | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="graph-container" ref={reactFlowWrapper}> | |
| {/* Toolbar */} | |
| <div className="graph-toolbar"> | |
| <div className="d-flex align-items-center gap-3"> | |
| <h5 className="mb-0"> | |
| <i className="bi bi-diagram-3 me-2"></i> | |
| Knowledge Graph | |
| </h5> | |
| <span className="badge bg-secondary">{pdfFile?.name}</span> | |
| </div> | |
| <div className="d-flex align-items-center gap-2"> | |
| <button className="btn btn-outline-primary btn-sm" onClick={() => addQuestionNode({})}> | |
| <i className="bi bi-question-circle me-1"></i>Add Question | |
| </button> | |
| <button className="btn btn-outline-success btn-sm" onClick={() => addNoteNode({})}> | |
| <i className="bi bi-sticky me-1"></i>Add Note | |
| </button> | |
| <button className="btn btn-outline-info btn-sm" onClick={saveGraph}> | |
| <i className="bi bi-save me-1"></i>Save | |
| </button> | |
| <button className="btn btn-outline-secondary btn-sm" onClick={exportGraph}> | |
| <i className="bi bi-download me-1"></i>Export | |
| </button> | |
| </div> | |
| </div> | |
| {/* Graph Canvas */} | |
| <div className="graph-canvas"> | |
| <ReactFlow | |
| nodes={nodes} | |
| edges={edges} | |
| onNodesChange={onNodesChange} | |
| onEdgesChange={onEdgesChange} | |
| onConnect={onConnect} | |
| onNodeClick={onNodeClick} | |
| nodeTypes={nodeTypes} | |
| fitView | |
| snapToGrid={true} | |
| snapGrid={[15, 15]} | |
| > | |
| <Controls /> | |
| <MiniMap | |
| nodeStrokeColor={(n) => { | |
| if (n.type === 'question') return '#0d6efd'; | |
| if (n.type === 'note') return '#198754'; | |
| if (n.type === 'page') return '#6f42c1'; | |
| return '#666'; | |
| }} | |
| nodeColor={(n) => { | |
| if (n.type === 'question') return '#0d6efd'; | |
| if (n.type === 'note') return '#198754'; | |
| if (n.type === 'page') return '#6f42c1'; | |
| return '#666'; | |
| }} | |
| bgColor="#2b3035" | |
| /> | |
| <Background color="#495057" gap={20} /> | |
| </ReactFlow> | |
| </div> | |
| {/* Side Panel */} | |
| <div className={`side-panel ${showPanel ? 'open' : ''}`}> | |
| <div className="side-panel-header"> | |
| <h6 className="mb-0"> | |
| <i className="bi bi-info-circle me-2"></i> | |
| Node Details | |
| </h6> | |
| <button | |
| className="btn btn-close btn-close-white" | |
| onClick={() => setShowPanel(false)} | |
| ></button> | |
| </div> | |
| <div className="side-panel-body"> | |
| {selectedNode ? ( | |
| <div> | |
| <div className="mb-3"> | |
| <strong>Type:</strong> {selectedNode.type} | |
| </div> | |
| <div className="mb-3"> | |
| <strong>ID:</strong> {selectedNode.id} | |
| </div> | |
| <div className="mb-3"> | |
| <strong>Position:</strong> X: {selectedNode.position.x}, Y: {selectedNode.position.y} | |
| </div> | |
| <div className="mb-3"> | |
| <strong>Data:</strong> | |
| <pre className="bg-dark p-2 rounded mt-2"> | |
| {JSON.stringify(selectedNode.data, null, 2)} | |
| </pre> | |
| </div> | |
| <hr /> | |
| <button | |
| className="btn btn-danger btn-sm w-100" | |
| onClick={() => { | |
| setNodes(nodes.filter(n => n.id !== selectedNode.id)); | |
| setShowPanel(false); | |
| }} | |
| > | |
| <i className="bi bi-trash me-2"></i>Delete Node | |
| </button> | |
| </div> | |
| ) : ( | |
| <p className="text-muted">Select a node to view details</p> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| // Render the app | |
| const root = ReactDOM.createRoot(document.getElementById('graph-app')); | |
| root.render(<GraphApp />); | |
| </script> | |
| </body> | |
| </html> | |