feat: implement admin knowledge extraction system with document upload and AI pipeline integration
This commit is contained in:
@@ -6,8 +6,9 @@ import { useApp } from './store/AppContext'
|
|||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
|
|
||||||
|
import Admin from './pages/Admin'
|
||||||
|
|
||||||
// Placeholder components for routing structure
|
// Placeholder components for routing structure
|
||||||
const Admin = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Admin</h1><p className="mt-4">Kennisbeheer and Source Upload.</p></div>
|
|
||||||
const Testen = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Weektest</h1><p className="mt-4">Start your weekly test here.</p></div>
|
const Testen = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Weektest</h1><p className="mt-4">Start your weekly test here.</p></div>
|
||||||
const Leren = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Leren</h1><p className="mt-4">Start your weekly learning session.</p></div>
|
const Leren = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Leren</h1><p className="mt-4">Start your weekly learning session.</p></div>
|
||||||
const Leaderboard = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Leaderboard</h1><p className="mt-4">See who is on top!</p></div>
|
const Leaderboard = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Leaderboard</h1><p className="mt-4">See who is on top!</p></div>
|
||||||
|
|||||||
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;
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { storage } from './storage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anthropic API Service
|
* Anthropic API Service
|
||||||
* Handles communication with the /v1/messages endpoint.
|
* Handles communication with the /v1/messages endpoint.
|
||||||
@@ -16,7 +18,8 @@ export const anthropicApi = {
|
|||||||
// In a real application, the API key should not be exposed to the client.
|
// In a real application, the API key should not be exposed to the client.
|
||||||
// For this prototype, we'll assume it's passed or available in the environment,
|
// For this prototype, we'll assume it's passed or available in the environment,
|
||||||
// or proxied via a secure endpoint if deployed.
|
// or proxied via a secure endpoint if deployed.
|
||||||
const apiKey = import.meta.env.VITE_ANTHROPIC_API_KEY || 'no-key-provided';
|
// Prioritize key from local storage (set via Admin Settings)
|
||||||
|
const apiKey = storage.get('admin:anthropic_key') || import.meta.env.VITE_ANTHROPIC_API_KEY || 'no-key-provided';
|
||||||
|
|
||||||
while (retries <= maxRetries) {
|
while (retries <= maxRetries) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
112
src/lib/extractionPipeline.js
Normal file
112
src/lib/extractionPipeline.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { anthropicApi } from './api';
|
||||||
|
import { storage } from './storage';
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `Je bent een AI-kennisextractor voor Respellion, een IT-bedrijf gericht op "radicale transparantie".
|
||||||
|
Je krijgt een brontekst. Jouw taak is om kernconcepten, rollen, processen en feiten te extraheren en deze als een gestructureerde JSON Kennisgraaf terug te geven.
|
||||||
|
|
||||||
|
Geef ALTIJD een valide JSON object terug in het volgende formaat:
|
||||||
|
{
|
||||||
|
"topics": [
|
||||||
|
{
|
||||||
|
"id": "unieke-slug",
|
||||||
|
"label": "Titel van onderwerp",
|
||||||
|
"type": "concept | role | process | fact",
|
||||||
|
"description": "Een beknopte, heldere uitleg van max 3 zinnen."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"relations": [
|
||||||
|
{
|
||||||
|
"source": "id-van-topic-1",
|
||||||
|
"target": "id-van-topic-2",
|
||||||
|
"type": "related_to | depends_on | part_of | executed_by"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Zorg dat je alleen JSON teruggeeft, geen markdown blokken of andere tekst.`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voert tekst door de Anthropic API en slaat de resulterende topics op.
|
||||||
|
* @param {string} textContent De te analyseren tekst.
|
||||||
|
* @param {string} sourceName Naam van de bron (voor tracking).
|
||||||
|
*/
|
||||||
|
export async function processSourceText(textContent, sourceName) {
|
||||||
|
// 1. Sla de bron eerst op als "processing"
|
||||||
|
const sourceId = `src_${Date.now()}`;
|
||||||
|
const newSource = {
|
||||||
|
id: sourceId,
|
||||||
|
name: sourceName,
|
||||||
|
status: 'processing',
|
||||||
|
date: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const sources = storage.get('admin:sources', []);
|
||||||
|
storage.set('admin:sources', [newSource, ...sources]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2. Roep Anthropic API aan
|
||||||
|
const responseText = await anthropicApi.generateContent(SYSTEM_PROMPT, `Analyseer de volgende tekst:\n\n${textContent}`);
|
||||||
|
|
||||||
|
// 3. Parse JSON (veilig)
|
||||||
|
let extractedData;
|
||||||
|
try {
|
||||||
|
// Zoek naar JSON in de response (indien het toch in markdown was verpakt)
|
||||||
|
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
||||||
|
const jsonStr = jsonMatch ? jsonMatch[0] : responseText;
|
||||||
|
extractedData = JSON.parse(jsonStr);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('AI response was geen geldige JSON.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Deduplicatie en opslag van Topics en Relaties
|
||||||
|
mergeKnowledgeGraph(extractedData);
|
||||||
|
|
||||||
|
// 5. Markeer bron als voltooid
|
||||||
|
updateSourceStatus(sourceId, 'completed');
|
||||||
|
|
||||||
|
return { success: true, data: extractedData };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Markeer bron als mislukt
|
||||||
|
updateSourceStatus(sourceId, 'failed', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSourceStatus(sourceId, status, errorMsg = '') {
|
||||||
|
const sources = storage.get('admin:sources', []);
|
||||||
|
const updated = sources.map(s =>
|
||||||
|
s.id === sourceId ? { ...s, status, error: errorMsg } : s
|
||||||
|
);
|
||||||
|
storage.set('admin:sources', updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeKnowledgeGraph(newData) {
|
||||||
|
const existingTopics = storage.get('kb:topics', []);
|
||||||
|
const existingRelations = storage.get('kb:relations', []);
|
||||||
|
|
||||||
|
// Simpele deduplicatie op ID
|
||||||
|
const topicsMap = new Map(existingTopics.map(t => [t.id, t]));
|
||||||
|
|
||||||
|
if (newData.topics && Array.isArray(newData.topics)) {
|
||||||
|
for (const t of newData.topics) {
|
||||||
|
if (!topicsMap.has(t.id)) {
|
||||||
|
topicsMap.set(t.id, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Voeg relaties toe (we gaan er nu vanuit dat ze uniek genoeg zijn of dubbele negeren)
|
||||||
|
const newRelations = [...existingRelations];
|
||||||
|
if (newData.relations && Array.isArray(newData.relations)) {
|
||||||
|
for (const r of newData.relations) {
|
||||||
|
// Simpele check om exacte duplicaten te voorkomen
|
||||||
|
const isDup = newRelations.some(ex => ex.source === r.source && ex.target === r.target && ex.type === r.type);
|
||||||
|
if (!isDup) {
|
||||||
|
newRelations.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.set('kb:topics', Array.from(topicsMap.values()));
|
||||||
|
storage.set('kb:relations', newRelations);
|
||||||
|
}
|
||||||
@@ -11,13 +11,13 @@ export const storage = {
|
|||||||
* @param {string} key - e.g. 'kb:topics'
|
* @param {string} key - e.g. 'kb:topics'
|
||||||
* @returns {any}
|
* @returns {any}
|
||||||
*/
|
*/
|
||||||
get(key) {
|
get(key, defaultValue = null) {
|
||||||
try {
|
try {
|
||||||
const item = window.localStorage.getItem(STORAGE_PREFIX + key);
|
const item = window.localStorage.getItem(STORAGE_PREFIX + key);
|
||||||
return item ? JSON.parse(item) : null;
|
return item ? JSON.parse(item) : defaultValue;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Error reading ${key} from storage:`, e);
|
console.error(`Error reading ${key} from storage:`, e);
|
||||||
return null;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
176
src/pages/Admin/index.jsx
Normal file
176
src/pages/Admin/index.jsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Database, FileText, Settings, Users, Network, Clock, CheckCircle2, AlertCircle, Save } from 'lucide-react';
|
||||||
|
import Card from '../../components/ui/Card';
|
||||||
|
import Tag from '../../components/ui/Tag';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
import Input from '../../components/ui/Input';
|
||||||
|
import { storage } from '../../lib/storage';
|
||||||
|
import UploadZone from '../../components/admin/UploadZone';
|
||||||
|
import KnowledgeGraph from '../../components/admin/KnowledgeGraph';
|
||||||
|
|
||||||
|
const Admin = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState('bronnen');
|
||||||
|
const [sources, setSources] = useState([]);
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const [saveStatus, setSaveStatus] = useState(null);
|
||||||
|
|
||||||
|
const loadSources = () => {
|
||||||
|
setSources(storage.get('admin:sources', []));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'bronnen') {
|
||||||
|
loadSources();
|
||||||
|
}
|
||||||
|
if (activeTab === 'instellingen') {
|
||||||
|
setApiKey(storage.get('admin:anthropic_key', ''));
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
const saveSettings = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
storage.set('admin:anthropic_key', apiKey);
|
||||||
|
setSaveStatus('Opgeslagen!');
|
||||||
|
setTimeout(() => setSaveStatus(null), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col md:flex-row h-[calc(100vh-64px)] overflow-hidden">
|
||||||
|
{/* Admin Sidebar */}
|
||||||
|
<div className="w-full md:w-64 bg-paper border-r border-bg-warm flex-shrink-0 flex flex-row md:flex-col p-4 gap-2 overflow-x-auto md:overflow-y-auto">
|
||||||
|
<h2 className="hidden md:block text-teal font-bold text-lg mb-4 px-2">Kennisbeheer</h2>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('bronnen')}
|
||||||
|
className={`flex flex-col md:flex-row items-center gap-3 p-3 rounded-[var(--r-sm)] transition-colors min-w-[80px] md:min-w-0 ${activeTab === 'bronnen' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50 hover:text-fg'}`}
|
||||||
|
>
|
||||||
|
<Database size={20} />
|
||||||
|
<span className="text-sm mt-1 md:mt-0">Bronnen</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('graaf')}
|
||||||
|
className={`flex flex-col md:flex-row items-center gap-3 p-3 rounded-[var(--r-sm)] transition-colors min-w-[80px] md:min-w-0 ${activeTab === 'graaf' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50 hover:text-fg'}`}
|
||||||
|
>
|
||||||
|
<Network size={20} />
|
||||||
|
<span className="text-sm mt-1 md:mt-0">Graaf</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('gebruikers')}
|
||||||
|
className={`flex flex-col md:flex-row items-center gap-3 p-3 rounded-[var(--r-sm)] transition-colors min-w-[80px] md:min-w-0 ${activeTab === 'gebruikers' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50 hover:text-fg'}`}
|
||||||
|
>
|
||||||
|
<Users size={20} />
|
||||||
|
<span className="text-sm mt-1 md:mt-0">Team</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('instellingen')}
|
||||||
|
className={`flex flex-col md:flex-row items-center gap-3 p-3 rounded-[var(--r-sm)] transition-colors min-w-[80px] md:min-w-0 mt-0 md:mt-auto ${activeTab === 'instellingen' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50 hover:text-fg'}`}
|
||||||
|
>
|
||||||
|
<Settings size={20} />
|
||||||
|
<span className="text-sm mt-1 md:mt-0">Settings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Admin Main Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 md:p-8 bg-bg pb-24 md:pb-8">
|
||||||
|
{activeTab === 'bronnen' && (
|
||||||
|
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
||||||
|
<h1 className="text-3xl text-teal mb-2">Bronmateriaal</h1>
|
||||||
|
<p className="text-fg-muted mb-8">Upload bestanden of geef URL's op voor de AI-kennisextractie.</p>
|
||||||
|
|
||||||
|
<UploadZone onUploadComplete={loadSources} />
|
||||||
|
|
||||||
|
<h3 className="text-xl mb-4 mt-12">Recente Bronnen</h3>
|
||||||
|
<Card className="p-0 border border-bg-warm overflow-hidden">
|
||||||
|
<div className="divide-y divide-bg-warm">
|
||||||
|
{sources.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-fg-muted">Geen bronnen geüpload.</div>
|
||||||
|
) : (
|
||||||
|
sources.map((source) => (
|
||||||
|
<div key={source.id} className="p-4 flex items-center justify-between hover:bg-bg-warm/30 transition-colors">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={`p-2 rounded-full ${
|
||||||
|
source.status === 'completed' ? 'bg-teal-50 text-teal-600' :
|
||||||
|
source.status === 'processing' ? 'bg-purple-50 text-purple-600 animate-pulse' :
|
||||||
|
'bg-red-50 text-red-600'
|
||||||
|
}`}>
|
||||||
|
<FileText size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{source.name}</p>
|
||||||
|
<p className="text-xs text-fg-muted flex items-center gap-1 mt-1">
|
||||||
|
<Clock size={12} /> {new Date(source.date).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{source.status === 'completed' && <Tag variant="success" className="flex items-center gap-1"><CheckCircle2 size={12}/> Voltooid</Tag>}
|
||||||
|
{source.status === 'processing' && <Tag variant="accent" className="flex items-center gap-1"><Clock size={12}/> Bezig</Tag>}
|
||||||
|
{source.status === 'failed' && <Tag variant="dark" className="bg-red-100 text-red-800 flex items-center gap-1"><AlertCircle size={12}/> Mislukt</Tag>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'graaf' && (
|
||||||
|
<div className="animate-in fade-in duration-300 h-full flex flex-col">
|
||||||
|
<h1 className="text-3xl text-teal mb-2">Kennisgraaf</h1>
|
||||||
|
<p className="text-fg-muted mb-4">Visualisatie van de opgebouwde Respellion kennis.</p>
|
||||||
|
<Card className="flex-1 p-0 border border-bg-warm overflow-hidden bg-paper min-h-[400px]">
|
||||||
|
<KnowledgeGraph />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'gebruikers' && (
|
||||||
|
<div className="animate-in fade-in duration-300">
|
||||||
|
<h1 className="text-3xl text-teal mb-2">Gebruikersbeheer</h1>
|
||||||
|
<Card className="border border-bg-warm">
|
||||||
|
<p className="text-fg-muted">Hier kunnen medewerkers worden toegevoegd en beheerd.</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'instellingen' && (
|
||||||
|
<div className="animate-in fade-in duration-300 max-w-2xl">
|
||||||
|
<h1 className="text-3xl text-teal mb-2">Instellingen</h1>
|
||||||
|
<p className="text-fg-muted mb-8">Beheer applicatie-instellingen en API-sleutels.</p>
|
||||||
|
|
||||||
|
<Card className="border border-bg-warm">
|
||||||
|
<form onSubmit={saveSettings} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium mb-4">AI Configuratie</h3>
|
||||||
|
<Input
|
||||||
|
label="Anthropic API Key"
|
||||||
|
type="password"
|
||||||
|
placeholder="sk-ant-..."
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-fg-muted mt-2">
|
||||||
|
Deze sleutel wordt lokaal in je browser opgeslagen (`localStorage`) en wordt gebruikt voor alle AI-interacties.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button type="submit">
|
||||||
|
<Save size={18} className="mr-2" /> Instellingen Opslaan
|
||||||
|
</Button>
|
||||||
|
{saveStatus && <span className="text-teal font-medium animate-in fade-out duration-1000 fill-mode-forwards">{saveStatus}</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Admin;
|
||||||
Reference in New Issue
Block a user