feat: implement admin knowledge extraction system with document upload and AI pipeline integration
This commit is contained in:
191
src/components/admin/KnowledgeGraph.jsx
Normal file
191
src/components/admin/KnowledgeGraph.jsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import * as d3 from 'd3';
|
||||
import { storage } from '../../lib/storage';
|
||||
|
||||
const KnowledgeGraph = () => {
|
||||
const svgRef = useRef(null);
|
||||
const wrapperRef = useRef(null);
|
||||
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
|
||||
const [selectedNode, setSelectedNode] = useState(null);
|
||||
|
||||
// Load data
|
||||
const topics = storage.get('kb:topics', []);
|
||||
const relations = storage.get('kb:relations', []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wrapperRef.current) return;
|
||||
const { width, height } = wrapperRef.current.getBoundingClientRect();
|
||||
setDimensions({ width, height });
|
||||
|
||||
const handleResize = () => {
|
||||
if (wrapperRef.current) {
|
||||
const { width, height } = wrapperRef.current.getBoundingClientRect();
|
||||
setDimensions({ width, height });
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || topics.length === 0) return;
|
||||
|
||||
const { width, height } = dimensions;
|
||||
|
||||
// Clear previous render
|
||||
d3.select(svgRef.current).selectAll('*').remove();
|
||||
|
||||
// Setup nodes and links
|
||||
// Copy objects because d3 modifies them
|
||||
const nodes = topics.map(d => ({ ...d }));
|
||||
const links = relations.map(d => ({ ...d }));
|
||||
|
||||
// Create SVG container with zoom support
|
||||
const svg = d3.select(svgRef.current)
|
||||
.attr('viewBox', [0, 0, width, height])
|
||||
.attr('width', width)
|
||||
.attr('height', height);
|
||||
|
||||
const g = svg.append('g');
|
||||
|
||||
const zoom = d3.zoom()
|
||||
.scaleExtent([0.1, 4])
|
||||
.on('zoom', (event) => {
|
||||
g.attr('transform', event.transform);
|
||||
});
|
||||
|
||||
svg.call(zoom);
|
||||
|
||||
// Color scale for node types
|
||||
const color = d3.scaleOrdinal()
|
||||
.domain(['concept', 'role', 'process', 'fact'])
|
||||
.range(['#1F5560', '#5C1DD8', '#AFC0FF', '#E2E8F0']);
|
||||
|
||||
// Setup force simulation
|
||||
const simulation = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink(links).id(d => d.id).distance(100))
|
||||
.force('charge', d3.forceManyBody().strength(-300))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collide', d3.forceCollide().radius(40));
|
||||
|
||||
// Draw links
|
||||
const link = g.append('g')
|
||||
.attr('stroke', 'var(--color-bg-warm)')
|
||||
.attr('stroke-opacity', 0.6)
|
||||
.selectAll('line')
|
||||
.data(links)
|
||||
.join('line')
|
||||
.attr('stroke-width', 2);
|
||||
|
||||
// Draw nodes
|
||||
const node = g.append('g')
|
||||
.selectAll('g')
|
||||
.data(nodes)
|
||||
.join('g')
|
||||
.call(drag(simulation));
|
||||
|
||||
// Add circles to nodes
|
||||
node.append('circle')
|
||||
.attr('r', 20)
|
||||
.attr('fill', d => color(d.type) || '#1F5560')
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 2)
|
||||
.on('click', (event, d) => {
|
||||
setSelectedNode(d);
|
||||
});
|
||||
|
||||
// Add labels to nodes
|
||||
node.append('text')
|
||||
.text(d => d.label)
|
||||
.attr('x', 25)
|
||||
.attr('y', 5)
|
||||
.attr('font-size', '12px')
|
||||
.attr('fill', 'var(--color-fg)')
|
||||
.attr('class', 'font-mono select-none pointer-events-none');
|
||||
|
||||
simulation.on('tick', () => {
|
||||
link
|
||||
.attr('x1', d => d.source.x)
|
||||
.attr('y1', d => d.source.y)
|
||||
.attr('x2', d => d.target.x)
|
||||
.attr('y2', d => d.target.y);
|
||||
|
||||
node
|
||||
.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
|
||||
// Drag behavior
|
||||
function drag(simulation) {
|
||||
function dragstarted(event) {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
event.subject.fx = event.subject.x;
|
||||
event.subject.fy = event.subject.y;
|
||||
}
|
||||
|
||||
function dragged(event) {
|
||||
event.subject.fx = event.x;
|
||||
event.subject.fy = event.y;
|
||||
}
|
||||
|
||||
function dragended(event) {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
event.subject.fx = null;
|
||||
event.subject.fy = null;
|
||||
}
|
||||
|
||||
return d3.drag()
|
||||
.on('start', dragstarted)
|
||||
.on('drag', dragged)
|
||||
.on('end', dragended);
|
||||
}
|
||||
|
||||
return () => {
|
||||
simulation.stop();
|
||||
};
|
||||
}, [dimensions, topics, relations]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full flex flex-col md:flex-row">
|
||||
<div ref={wrapperRef} className="flex-1 h-[400px] md:h-full cursor-grab active:cursor-grabbing border-r border-bg-warm">
|
||||
{topics.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-fg-muted p-8 text-center">
|
||||
Nog geen kennisgraaf data. Upload eerst bronnen via het Bronmateriaal tabblad.
|
||||
</div>
|
||||
) : (
|
||||
<svg ref={svgRef} className="w-full h-full" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Node Info Panel */}
|
||||
<div className="w-full md:w-80 bg-paper p-6 overflow-y-auto border-t md:border-t-0 border-bg-warm">
|
||||
<h3 className="font-bold text-lg mb-4 text-teal">Node Details</h3>
|
||||
{selectedNode ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Label</p>
|
||||
<p className="font-medium">{selectedNode.label}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Type</p>
|
||||
<span className="inline-block px-2 py-1 bg-bg-warm rounded-[var(--r-pill)] text-xs font-mono">
|
||||
{selectedNode.type}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Beschrijving</p>
|
||||
<p className="text-sm leading-relaxed">{selectedNode.description}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">ID</p>
|
||||
<p className="text-xs font-mono text-fg-muted break-all">{selectedNode.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-fg-muted">Klik op een node in de graaf om details te bekijken.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KnowledgeGraph;
|
||||
137
src/components/admin/UploadZone.jsx
Normal file
137
src/components/admin/UploadZone.jsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { UploadCloud, Link as LinkIcon, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { processSourceText } from '../../lib/extractionPipeline';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import Input from '../ui/Input';
|
||||
|
||||
const UploadZone = ({ onUploadComplete }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [url, setUrl] = useState('');
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [status, setStatus] = useState(null); // { type: 'error' | 'success', msg: string }
|
||||
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const processFile = async (file) => {
|
||||
setIsProcessing(true);
|
||||
setStatus(null);
|
||||
try {
|
||||
const text = await file.text();
|
||||
await processSourceText(text, file.name);
|
||||
setStatus({ type: 'success', msg: `Succesvol verwerkt: ${file.name}` });
|
||||
if (onUploadComplete) onUploadComplete();
|
||||
} catch (error) {
|
||||
setStatus({ type: 'error', msg: `Fout bij verwerken: ${error.message}` });
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file.type === 'text/plain' || file.name.endsWith('.md')) {
|
||||
processFile(file);
|
||||
} else {
|
||||
setStatus({ type: 'error', msg: 'Alleen .txt en .md bestanden worden momenteel ondersteund.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
processFile(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!url) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
setStatus(null);
|
||||
try {
|
||||
// In a real scenario, this would call a backend proxy to bypass CORS.
|
||||
// For this prototype, we'll simulate fetching or only support text URLs.
|
||||
setStatus({ type: 'error', msg: 'URL import is experimenteel en momenteel uitgeschakeld ivm CORS restricties in browser.' });
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card
|
||||
className={`border-2 border-dashed transition-colors flex flex-col items-center justify-center py-12 ${
|
||||
isDragging ? 'border-teal bg-teal/5' : 'border-bg-warm bg-bg-warm/20'
|
||||
} ${isProcessing ? 'opacity-50 pointer-events-none' : ''}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<UploadCloud size={48} className="text-teal/40 mb-4" />
|
||||
<p className="font-medium text-lg">Sleep bestanden hierheen</p>
|
||||
<p className="text-sm text-fg-muted mb-4">Ondersteunt .txt en .md (Max 5MB)</p>
|
||||
<Button variant="outline" type="button" disabled={isProcessing}>
|
||||
{isProcessing ? 'Bezig met AI extractie...' : 'Bladeren op apparaat'}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
accept=".txt,.md"
|
||||
className="hidden"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 border-t border-bg-warm"></div>
|
||||
<span className="text-sm text-fg-muted uppercase tracking-wider">OF</span>
|
||||
<div className="flex-1 border-t border-bg-warm"></div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleUrlSubmit} className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Importeer van URL"
|
||||
placeholder="https://wiki.respellion.nl/artikel"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={isProcessing || !url}>
|
||||
<LinkIcon size={18} className="mr-2" /> Import
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{status && (
|
||||
<div className={`p-4 rounded-[var(--r-sm)] flex items-start gap-3 ${
|
||||
status.type === 'error' ? 'bg-red-50 text-red-900 border border-red-200' : 'bg-teal-50 text-teal-900 border border-teal-200'
|
||||
}`}>
|
||||
{status.type === 'error' ? <AlertCircle className="text-red-500 flex-shrink-0" /> : <CheckCircle className="text-teal-600 flex-shrink-0" />}
|
||||
<div>
|
||||
<p className="font-medium">{status.type === 'error' ? 'Fout' : 'Succes'}</p>
|
||||
<p className="text-sm">{status.msg}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadZone;
|
||||
Reference in New Issue
Block a user