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'; 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 [isAnalyzing, setIsAnalyzing] = useState(false); const [analyzeError, setAnalyzeError] = useState(null); const [newRelData, setNewRelData] = useState({ target: '', type: 'related_to' }); 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]); 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; 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 nodes = filteredTopics.map(d => ({ ...d })); const links = filteredRelations.map(d => ({ ...d })); 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); const color = d3.scaleOrdinal() .domain(['concept', 'role', 'process']) .range(['#1F5560', '#5C1DD8', '#AFC0FF']); 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') .attr('stroke', 'var(--color-bg-warm)') .attr('stroke-opacity', 0.6) .selectAll('line') .data(links) .join('line') .attr('stroke-width', 2); const node = g.append('g') .selectAll('g') .data(nodes) .join('g') .call(drag(simulation)); 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('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); }); 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})`); }); 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]); const startEdit = () => { setEditData({ label: selectedNode.label, type: selectedNode.type, description: selectedNode.description }); setIsEditing(true); }; 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 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); setSelectedNode(null); setIsEditing(false); } }; 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; 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; setIsAnalyzing(true); setAnalyzeError(null); try { const currentTopics = await db.getTopics(); const currentRelations = await 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. Rules: 1. Identify topics that mean exactly the same thing. Choose one to keep, one to delete (merges). 2. Identify topics that are too vague, irrelevant, or malformed (deletions). 3. Identify missing logical relations (depends_on, part_of, related_to, executed_by) between conceptually linked topics (newRelations). 4. Evaluate learning_relevance. Mark purely operational topics (printer guides, etc.) as "exclude"; low-priority as "peripheral" (relevanceUpdates). 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 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, tools: [EMIT_GRAPH_ACTIONS_TOOL], toolChoice: { type: 'tool', name: EMIT_GRAPH_ACTIONS_TOOL.name }, maxTokens: 4096, }); const actions = llmResult.toolUses[0]?.input; if (!actions) throw new Error('Graph analysis did not emit a tool result.'); 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 => ({ ...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) ); await db.saveTopics(updatedTopics); await db.saveRelations(updatedRelations); setTopics(updatedTopics); setRelations(updatedRelations); setSelectedNode(null); } catch (e) { setAnalyzeError(e.message || 'Analysis failed. Please try again.'); } finally { setIsAnalyzing(false); } }; return (
{topics.length === 0 ? (
No knowledge graph data yet. Upload source material in the Sources tab first.
) : ( )}
{analyzeError && (

{analyzeError}

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

Node Details

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