feat: implement admin knowledge extraction system with document upload and AI pipeline integration

This commit is contained in:
RaymondVerhoef
2026-05-10 11:18:48 +02:00
parent f8d0c68f84
commit 260644b41a
7 changed files with 625 additions and 5 deletions

View 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;

View 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;