From 6ea8860b9684122568b3308f57b4553b1e742c48 Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Wed, 27 May 2026 15:18:01 +0200 Subject: [PATCH] feat: refactor KnowledgeGraph component and add NodeDetailPanel for enhanced node management --- src/components/admin/KnowledgeGraph.jsx | 570 ++++++------------ .../admin/graph/NodeDetailPanel.jsx | 290 +++++++++ 2 files changed, 462 insertions(+), 398 deletions(-) create mode 100644 src/components/admin/graph/NodeDetailPanel.jsx diff --git a/src/components/admin/KnowledgeGraph.jsx b/src/components/admin/KnowledgeGraph.jsx index 40db606..aae1a29 100644 --- a/src/components/admin/KnowledgeGraph.jsx +++ b/src/components/admin/KnowledgeGraph.jsx @@ -1,106 +1,95 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import * as d3 from 'd3'; -import { Trash2, Edit2, Save, X, RefreshCw, AlertCircle, Plus, Link as LinkIcon } from 'lucide-react'; import * as db from '../../lib/db'; import { callLLM } from '../../lib/llm'; import { EMIT_GRAPH_ACTIONS_TOOL } from '../../lib/llmTools'; -import Button from '../ui/Button'; -import SuggestionsQueue from './SuggestionsQueue'; +import { useGraphData } from '../../hooks/useGraphData'; +import GraphControls from './graph/GraphControls'; +import NodeDetailPanel from './graph/NodeDetailPanel'; +/** + * Knowledge-graph admin view. + * + * This component is the orchestrator: it owns the D3 canvas, the selected-node + * cursor, and the AI analysis flow. All data fetching lives in useGraphData; + * all panel UI lives in GraphControls and NodeDetailPanel. + * + * Line-count target: stay below 200 lines. Move any growing logic to a hook. + */ const KnowledgeGraph = () => { const svgRef = useRef(null); const wrapperRef = useRef(null); + const [dimensions, setDimensions] = useState({ width: 800, height: 600 }); const [selectedNode, setSelectedNode] = useState(null); - const [isEditing, setIsEditing] = useState(false); - const [editData, setEditData] = useState({}); + const [showExcludeNodes, setShowExcludeNodes] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false); const [analyzeError, setAnalyzeError] = useState(null); - const [newRelData, setNewRelData] = useState({ target: '', type: 'related_to' }); + const { topics, relations, reload, updateTopic, deleteTopic, addRelation, removeRelation, bulkSave } = + useGraphData(); - const [topics, setTopics] = useState([]); - const [relations, setRelations] = useState([]); - const [showExcludeNodes, setShowExcludeNodes] = useState(false); - - const reloadKb = useCallback(() => { - Promise.all([db.getTopics(), db.getRelations()]).then(([allTopics, allRelations]) => { - const topicIds = new Set(allTopics.map(t => t.id)); - const validRelations = allRelations - .map(r => ({ - ...r, - source: r.source?.id || r.source, - target: r.target?.id || r.target - })) - .filter(r => topicIds.has(r.source) && topicIds.has(r.target)); - - setTopics(allTopics); - setRelations(validRelations); - }); - }, []); - - useEffect(() => { - reloadKb(); - const handler = () => reloadKb(); - window.addEventListener('respellion:kb-updated', handler); - return () => window.removeEventListener('respellion:kb-updated', handler); - }, [reloadKb]); + // ── Canvas sizing ──────────────────────────────────────────────────────────── 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 }); - } + const measure = () => { + const { width, height } = wrapperRef.current.getBoundingClientRect(); + setDimensions({ width, height }); }; - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); + measure(); + window.addEventListener('resize', measure); + return () => window.removeEventListener('resize', measure); }, []); + // ── D3 force graph ─────────────────────────────────────────────────────────── + useEffect(() => { if (!svgRef.current || topics.length === 0) return; const { width, height } = dimensions; d3.select(svgRef.current).selectAll('*').remove(); - const filteredTopics = showExcludeNodes ? topics : topics.filter(t => t.learning_relevance !== 'exclude'); - const filteredTopicIds = new Set(filteredTopics.map(t => t.id)); - const filteredRelations = relations.filter(r => - filteredTopicIds.has(r.source?.id || r.source) && - filteredTopicIds.has(r.target?.id || r.target) + const filteredTopics = showExcludeNodes + ? topics + : topics.filter(t => t.learning_relevance !== 'exclude'); + const filteredIds = new Set(filteredTopics.map(t => t.id)); + const filteredRelations = relations.filter( + r => filteredIds.has(r.source) && filteredIds.has(r.target), ); + // Spread every node/link so D3's simulation mutations don't touch React state. const nodes = filteredTopics.map(d => ({ ...d })); const links = filteredRelations.map(d => ({ ...d })); - const svg = d3.select(svgRef.current) + 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( + d3.zoom() + .scaleExtent([0.1, 4]) + .on('zoom', event => g.attr('transform', event.transform)), + ); - svg.call(zoom); - - const color = d3.scaleOrdinal() + const color = d3 + .scaleOrdinal() .domain(['concept', 'role', 'process']) .range(['#1F5560', '#5C1DD8', '#AFC0FF']); - const simulation = d3.forceSimulation(nodes) + 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)); - const link = g.append('g') + const link = g + .append('g') .attr('stroke', 'var(--color-bg-warm)') .attr('stroke-opacity', 0.6) .selectAll('line') @@ -108,29 +97,45 @@ const KnowledgeGraph = () => { .join('line') .attr('stroke-width', 2); - const node = g.append('g') + const node = g + .append('g') .selectAll('g') .data(nodes) .join('g') - .call(drag(simulation)); + .call( + d3.drag() + .on('start', (event) => { + if (!event.active) simulation.alphaTarget(0.3).restart(); + event.subject.fx = event.subject.x; + event.subject.fy = event.subject.y; + }) + .on('drag', (event) => { + event.subject.fx = event.x; + event.subject.fy = event.y; + }) + .on('end', (event) => { + if (!event.active) simulation.alphaTarget(0); + event.subject.fx = null; + event.subject.fy = null; + }), + ); - node.append('circle') + node + .append('circle') .attr('r', 20) .attr('fill', d => color(d.type) || '#1F5560') .attr('stroke', '#fff') .attr('stroke-width', 2) - .attr('stroke-dasharray', d => d.learning_relevance === 'exclude' ? '4,4' : 'none') + .attr('stroke-dasharray', d => (d.learning_relevance === 'exclude' ? '4,4' : 'none')) .attr('opacity', d => { if (d.learning_relevance === 'exclude') return 0.3; if (d.learning_relevance === 'peripheral') return 0.5; return 1; }) - .on('click', (event, d) => { - setSelectedNode(d); - setIsEditing(false); - }); + .on('click', (_event, d) => setSelectedNode(d)); - node.append('text') + node + .append('text') .text(d => d.label) .attr('x', 25) .attr('y', 5) @@ -147,100 +152,50 @@ const KnowledgeGraph = () => { node.attr('transform', d => `translate(${d.x},${d.y})`); }); - 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]); + }, [dimensions, topics, relations, showExcludeNodes]); - const startEdit = () => { - setEditData({ label: selectedNode.label, type: selectedNode.type, description: selectedNode.description }); - setIsEditing(true); - }; + // ── Node mutation handlers ─────────────────────────────────────────────────── - const saveEdit = async () => { - // If the admin touched learning_relevance, lock it so re-extraction won't overwrite the choice. - // But an explicit relevance_locked in editData (the unlock checkbox) always wins. - const relevanceChanged = - editData.learning_relevance !== undefined && - editData.learning_relevance !== selectedNode.learning_relevance; - const next = { ...editData }; - if (relevanceChanged && next.relevance_locked === undefined) next.relevance_locked = true; - await db.upsertTopic({ ...selectedNode, ...next }); - const updated = topics.map(t => t.id === selectedNode.id ? { ...t, ...next } : t); - setTopics(updated); - setSelectedNode({ ...selectedNode, ...next }); - setIsEditing(false); - }; + const handleNodeSave = useCallback( + async (updatedTopic) => { + await updateTopic(updatedTopic); + setSelectedNode(updatedTopic); + }, + [updateTopic], + ); - const deleteNode = async () => { - if (confirm(`Are you sure you want to delete "${selectedNode.label}"? This will also remove any relations connected to it.`)) { - const updatedTopics = topics.filter(t => t.id !== selectedNode.id); - const updatedRelations = relations - .map(r => ({ - ...r, - source: r.source?.id || r.source, - target: r.target?.id || r.target - })) - .filter(r => r.source !== selectedNode.id && r.target !== selectedNode.id); - - await db.saveTopics(updatedTopics); - await db.saveRelations(updatedRelations); - await db.deleteContent(selectedNode.id); - await db.setQuizBank(selectedNode.id, []); - - setTopics(updatedTopics); - setRelations(updatedRelations); + const handleNodeDelete = useCallback(async () => { + if (!selectedNode) return; + if ( + confirm( + `Are you sure you want to delete "${selectedNode.label}"? This will also remove any relations connected to it.`, + ) + ) { + await deleteTopic(selectedNode.id); setSelectedNode(null); - setIsEditing(false); } - }; + }, [selectedNode, deleteTopic]); - const addRelation = async () => { - if (!newRelData.target) return; - const isDup = relations.some(r => (r.source.id || r.source) === selectedNode.id && (r.target.id || r.target) === newRelData.target && r.type === newRelData.type); - if (isDup) return; + // ── AI graph analysis ──────────────────────────────────────────────────────── - const newRel = { source: selectedNode.id, target: newRelData.target, type: newRelData.type }; - await db.addRelation(newRel); - setRelations([...relations, newRel]); - setNewRelData({ target: '', type: 'related_to' }); - }; - - const removeRelation = async (sourceId, targetId, type) => { - await db.removeRelation(sourceId, targetId, type); - setRelations(relations.filter(r => !( - (r.source.id || r.source) === sourceId && - (r.target.id || r.target) === targetId && - r.type === type - ))); - }; - - - const analyzeGraph = async () => { - if (!confirm('This will send the entire graph to the AI to evaluate content, merge duplicates, and create new logical relations. This may take a moment. Proceed?')) return; + const analyzeGraph = useCallback(async () => { + if ( + !confirm( + 'This will send the entire graph to the AI to evaluate content, merge duplicates, and create new logical relations. This may take a moment. Proceed?', + ) + ) + return; setIsAnalyzing(true); setAnalyzeError(null); try { - const currentTopics = await db.getTopics(); - const currentRelations = await db.getRelations(); + // Fetch fresh data so analysis reflects any concurrent edits. + const [currentTopics, currentRelations] = await Promise.all([ + db.getTopics(), + db.getRelations(), + ]); const systemPrompt = `You are a strict Data Quality AI maintaining a Knowledge Graph for Respellion. Evaluate the provided topics and relations and emit the actions to take via the emit_graph_actions tool. @@ -253,22 +208,18 @@ Rules: Do not return the entire graph — only the actions to take.`; - // Send a compact representation to minimize token usage and avoid rate limits. - const compactTopics = currentTopics.map(({ id, label, type, learning_relevance }) => ({ id, label, type, learning_relevance })); - const compactRelations = currentRelations.map(r => ({ - source: r.source?.id || r.source, - target: r.target?.id || r.target, - type: r.type + const compactTopics = currentTopics.map(({ id, label, type, learning_relevance }) => ({ + id, label, type, learning_relevance, + })); + const compactRelations = currentRelations.map(({ source, target, type }) => ({ + source, target, type, })); - - const userPrompt = `Here is the current knowledge graph: -${JSON.stringify({ topics: compactTopics, relations: compactRelations })}`; const llmResult = await callLLM({ task: 'graph.analyze', tier: 'reasoning', system: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }], - user: userPrompt, + user: `Here is the current knowledge graph:\n${JSON.stringify({ topics: compactTopics, relations: compactRelations })}`, tools: [EMIT_GRAPH_ACTIONS_TOOL], toolChoice: { type: 'tool', name: EMIT_GRAPH_ACTIONS_TOOL.name }, maxTokens: 4096, @@ -280,69 +231,65 @@ ${JSON.stringify({ topics: compactTopics, relations: compactRelations })}`; let updatedTopics = [...currentTopics]; let updatedRelations = [...currentRelations]; - if (actions.merges && Array.isArray(actions.merges)) { - for (const merge of actions.merges) { - updatedTopics = updatedTopics.filter(t => t.id !== merge.deleteId); - updatedRelations = updatedRelations.map(r => { - if (r.source === merge.deleteId) return { ...r, source: merge.keepId }; - if (r.target === merge.deleteId) return { ...r, target: merge.keepId }; - return r; - }); - } - } - - if (actions.deletions && Array.isArray(actions.deletions)) { - updatedTopics = updatedTopics.filter(t => !actions.deletions.includes(t.id)); - updatedRelations = updatedRelations.filter(r => !actions.deletions.includes(r.source) && !actions.deletions.includes(r.target)); - } - - if (actions.newRelations && Array.isArray(actions.newRelations)) { - for (const newRel of actions.newRelations) { - const isDup = updatedRelations.some(r => r.source === newRel.source && r.target === newRel.target && r.type === newRel.type); - if (!isDup) updatedRelations.push(newRel); - } - } - - if (actions.relevanceUpdates && Array.isArray(actions.relevanceUpdates)) { - for (const update of actions.relevanceUpdates) { - const topicIndex = updatedTopics.findIndex(t => t.id === update.id); - if (topicIndex !== -1 && !updatedTopics[topicIndex].relevance_locked) { - updatedTopics[topicIndex] = { ...updatedTopics[topicIndex], learning_relevance: update.learning_relevance }; - } - } - } - - // Ensure all relations only reference existing nodes and are normalized to string IDs - const finalTopicIds = new Set(updatedTopics.map(t => t.id)); - updatedRelations = updatedRelations - .map(r => ({ + // Apply merges: remap all edges from the deleted twin to the surviving one. + for (const merge of actions.merges ?? []) { + updatedTopics = updatedTopics.filter(t => t.id !== merge.deleteId); + updatedRelations = updatedRelations.map(r => ({ ...r, - source: r.source?.id || r.source, - target: r.target?.id || r.target - })) - .filter(r => - r.source !== r.target && - finalTopicIds.has(r.source) && - finalTopicIds.has(r.target) + source: r.source === merge.deleteId ? merge.keepId : r.source, + target: r.target === merge.deleteId ? merge.keepId : r.target, + })); + } + + // Apply deletions. + if (actions.deletions?.length) { + const toDelete = new Set(actions.deletions); + updatedTopics = updatedTopics.filter(t => !toDelete.has(t.id)); + updatedRelations = updatedRelations.filter( + r => !toDelete.has(r.source) && !toDelete.has(r.target), ); + } - await db.saveTopics(updatedTopics); - await db.saveRelations(updatedRelations); + // Add new relations (skip duplicates). + for (const rel of actions.newRelations ?? []) { + const dup = updatedRelations.some( + r => r.source === rel.source && r.target === rel.target && r.type === rel.type, + ); + if (!dup) updatedRelations.push(rel); + } - setTopics(updatedTopics); - setRelations(updatedRelations); + // Apply relevance updates (skip locked topics). + for (const update of actions.relevanceUpdates ?? []) { + const idx = updatedTopics.findIndex(t => t.id === update.id); + if (idx !== -1 && !updatedTopics[idx].relevance_locked) { + updatedTopics[idx] = { ...updatedTopics[idx], learning_relevance: update.learning_relevance }; + } + } + + // Drop any edges that now reference non-existent nodes or are self-loops. + const finalIds = new Set(updatedTopics.map(t => t.id)); + updatedRelations = updatedRelations.filter( + r => r.source !== r.target && finalIds.has(r.source) && finalIds.has(r.target), + ); + + await bulkSave(updatedTopics, updatedRelations); setSelectedNode(null); - } catch (e) { setAnalyzeError(e.message || 'Analysis failed. Please try again.'); } finally { setIsAnalyzing(false); } - }; + }, [bulkSave]); + + // ── Render ─────────────────────────────────────────────────────────────────── return (
-
+ {/* D3 canvas */} +
{topics.length === 0 ? (
No knowledge graph data yet. Upload source material in the Sources tab first. @@ -352,199 +299,26 @@ ${JSON.stringify({ topics: compactTopics, relations: compactRelations })}`; )}
+ {/* Sidebar */}
-
- -
- - {analyzeError && ( -

- - {analyzeError} -

- )} -
-
- - {/* R42 chatbot suggestions queue */} -
- -
- -
-

Node Details

- {selectedNode && !isEditing && ( -
- - -
- )} -
- - {selectedNode ? ( - isEditing ? ( -
-
- - setEditData({...editData, label: e.target.value})} - /> -
-
- - -
-
- - - {selectedNode.relevance_locked && ( - - )} -
-
- -