diff --git a/src/App.jsx b/src/App.jsx index 5b00847..ace264f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -6,8 +6,9 @@ import { useApp } from './store/AppContext' import Login from './pages/Login' import Dashboard from './pages/Dashboard' +import Admin from './pages/Admin' + // Placeholder components for routing structure -const Admin = () =>

Admin

Kennisbeheer and Source Upload.

const Testen = () =>

Weektest

Start your weekly test here.

const Leren = () =>

Leren

Start your weekly learning session.

const Leaderboard = () =>

Leaderboard

See who is on top!

diff --git a/src/components/admin/KnowledgeGraph.jsx b/src/components/admin/KnowledgeGraph.jsx new file mode 100644 index 0000000..b55de94 --- /dev/null +++ b/src/components/admin/KnowledgeGraph.jsx @@ -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 ( +
+
+ {topics.length === 0 ? ( +
+ Nog geen kennisgraaf data. Upload eerst bronnen via het Bronmateriaal tabblad. +
+ ) : ( + + )} +
+ + {/* Node Info Panel */} +
+

Node Details

+ {selectedNode ? ( +
+
+

Label

+

{selectedNode.label}

+
+
+

Type

+ + {selectedNode.type} + +
+
+

Beschrijving

+

{selectedNode.description}

+
+
+

ID

+

{selectedNode.id}

+
+
+ ) : ( +

Klik op een node in de graaf om details te bekijken.

+ )} +
+
+ ); +}; + +export default KnowledgeGraph; diff --git a/src/components/admin/UploadZone.jsx b/src/components/admin/UploadZone.jsx new file mode 100644 index 0000000..03b57df --- /dev/null +++ b/src/components/admin/UploadZone.jsx @@ -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 ( +
+ fileInputRef.current?.click()} + > + +

Sleep bestanden hierheen

+

Ondersteunt .txt en .md (Max 5MB)

+ + +
+ +
+
+ OF +
+
+ +
+
+ setUrl(e.target.value)} + disabled={isProcessing} + /> +
+ +
+ + {status && ( +
+ {status.type === 'error' ? : } +
+

{status.type === 'error' ? 'Fout' : 'Succes'}

+

{status.msg}

+
+
+ )} +
+ ); +}; + +export default UploadZone; diff --git a/src/lib/api.js b/src/lib/api.js index 0658ef6..cfea2fa 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -1,3 +1,5 @@ +import { storage } from './storage'; + /** * Anthropic API Service * 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. // For this prototype, we'll assume it's passed or available in the environment, // 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) { try { diff --git a/src/lib/extractionPipeline.js b/src/lib/extractionPipeline.js new file mode 100644 index 0000000..843fa1a --- /dev/null +++ b/src/lib/extractionPipeline.js @@ -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); +} diff --git a/src/lib/storage.js b/src/lib/storage.js index 910a665..066ba95 100644 --- a/src/lib/storage.js +++ b/src/lib/storage.js @@ -11,13 +11,13 @@ export const storage = { * @param {string} key - e.g. 'kb:topics' * @returns {any} */ - get(key) { + get(key, defaultValue = null) { try { const item = window.localStorage.getItem(STORAGE_PREFIX + key); - return item ? JSON.parse(item) : null; + return item ? JSON.parse(item) : defaultValue; } catch (e) { console.error(`Error reading ${key} from storage:`, e); - return null; + return defaultValue; } }, diff --git a/src/pages/Admin/index.jsx b/src/pages/Admin/index.jsx new file mode 100644 index 0000000..c4d9439 --- /dev/null +++ b/src/pages/Admin/index.jsx @@ -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 ( +
+ {/* Admin Sidebar */} +
+

Kennisbeheer

+ + + + + + + + +
+ + {/* Admin Main Content */} +
+ {activeTab === 'bronnen' && ( +
+

Bronmateriaal

+

Upload bestanden of geef URL's op voor de AI-kennisextractie.

+ + + +

Recente Bronnen

+ +
+ {sources.length === 0 ? ( +
Geen bronnen geüpload.
+ ) : ( + sources.map((source) => ( +
+
+
+ +
+
+

{source.name}

+

+ {new Date(source.date).toLocaleString()} +

+
+
+
+ {source.status === 'completed' && Voltooid} + {source.status === 'processing' && Bezig} + {source.status === 'failed' && Mislukt} +
+
+ )) + )} +
+
+
+ )} + + {activeTab === 'graaf' && ( +
+

Kennisgraaf

+

Visualisatie van de opgebouwde Respellion kennis.

+ + + +
+ )} + + {activeTab === 'gebruikers' && ( +
+

Gebruikersbeheer

+ +

Hier kunnen medewerkers worden toegevoegd en beheerd.

+
+
+ )} + + {activeTab === 'instellingen' && ( +
+

Instellingen

+

Beheer applicatie-instellingen en API-sleutels.

+ + +
+
+

AI Configuratie

+ setApiKey(e.target.value)} + /> +

+ Deze sleutel wordt lokaal in je browser opgeslagen (`localStorage`) en wordt gebruikt voor alle AI-interacties. +

+
+ +
+ + {saveStatus && {saveStatus}} +
+
+
+
+ )} +
+
+ ); +}; + +export default Admin;