feat: refactor KnowledgeGraph component and add NodeDetailPanel for enhanced node management
Some checks failed
On Push to Main / test (push) Successful in 47s
On Push to Main / publish (push) Failing after 31m8s
On Push to Main / deploy-dev (push) Has been cancelled

This commit is contained in:
RaymondVerhoef
2026-05-27 15:18:01 +02:00
parent 7b6a5b4bf0
commit 6ea8860b96
2 changed files with 462 additions and 398 deletions

View File

@@ -1,106 +1,95 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import * as d3 from 'd3'; 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 * as db from '../../lib/db';
import { callLLM } from '../../lib/llm'; import { callLLM } from '../../lib/llm';
import { EMIT_GRAPH_ACTIONS_TOOL } from '../../lib/llmTools'; import { EMIT_GRAPH_ACTIONS_TOOL } from '../../lib/llmTools';
import Button from '../ui/Button'; import { useGraphData } from '../../hooks/useGraphData';
import SuggestionsQueue from './SuggestionsQueue'; 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 KnowledgeGraph = () => {
const svgRef = useRef(null); const svgRef = useRef(null);
const wrapperRef = useRef(null); const wrapperRef = useRef(null);
const [dimensions, setDimensions] = useState({ width: 800, height: 600 }); const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
const [selectedNode, setSelectedNode] = useState(null); const [selectedNode, setSelectedNode] = useState(null);
const [isEditing, setIsEditing] = useState(false); const [showExcludeNodes, setShowExcludeNodes] = useState(false);
const [editData, setEditData] = useState({});
const [isAnalyzing, setIsAnalyzing] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analyzeError, setAnalyzeError] = useState(null); 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([]); // ── Canvas sizing ────────────────────────────────────────────────────────────
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(() => { useEffect(() => {
if (!wrapperRef.current) return; if (!wrapperRef.current) return;
const { width, height } = wrapperRef.current.getBoundingClientRect(); const measure = () => {
setDimensions({ width, height }); 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); measure();
return () => window.removeEventListener('resize', handleResize); window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, []); }, []);
// ── D3 force graph ───────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!svgRef.current || topics.length === 0) return; if (!svgRef.current || topics.length === 0) return;
const { width, height } = dimensions; const { width, height } = dimensions;
d3.select(svgRef.current).selectAll('*').remove(); d3.select(svgRef.current).selectAll('*').remove();
const filteredTopics = showExcludeNodes ? topics : topics.filter(t => t.learning_relevance !== 'exclude'); const filteredTopics = showExcludeNodes
const filteredTopicIds = new Set(filteredTopics.map(t => t.id)); ? topics
const filteredRelations = relations.filter(r => : topics.filter(t => t.learning_relevance !== 'exclude');
filteredTopicIds.has(r.source?.id || r.source) && const filteredIds = new Set(filteredTopics.map(t => t.id));
filteredTopicIds.has(r.target?.id || r.target) 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 nodes = filteredTopics.map(d => ({ ...d }));
const links = filteredRelations.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('viewBox', [0, 0, width, height])
.attr('width', width) .attr('width', width)
.attr('height', height); .attr('height', height);
const g = svg.append('g'); const g = svg.append('g');
const zoom = d3.zoom() svg.call(
.scaleExtent([0.1, 4]) d3.zoom()
.on('zoom', (event) => { g.attr('transform', event.transform); }); .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']) .domain(['concept', 'role', 'process'])
.range(['#1F5560', '#5C1DD8', '#AFC0FF']); .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('link', d3.forceLink(links).id(d => d.id).distance(100))
.force('charge', d3.forceManyBody().strength(-300)) .force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2)) .force('center', d3.forceCenter(width / 2, height / 2))
.force('collide', d3.forceCollide().radius(40)); .force('collide', d3.forceCollide().radius(40));
const link = g.append('g') const link = g
.append('g')
.attr('stroke', 'var(--color-bg-warm)') .attr('stroke', 'var(--color-bg-warm)')
.attr('stroke-opacity', 0.6) .attr('stroke-opacity', 0.6)
.selectAll('line') .selectAll('line')
@@ -108,29 +97,45 @@ const KnowledgeGraph = () => {
.join('line') .join('line')
.attr('stroke-width', 2); .attr('stroke-width', 2);
const node = g.append('g') const node = g
.append('g')
.selectAll('g') .selectAll('g')
.data(nodes) .data(nodes)
.join('g') .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('r', 20)
.attr('fill', d => color(d.type) || '#1F5560') .attr('fill', d => color(d.type) || '#1F5560')
.attr('stroke', '#fff') .attr('stroke', '#fff')
.attr('stroke-width', 2) .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 => { .attr('opacity', d => {
if (d.learning_relevance === 'exclude') return 0.3; if (d.learning_relevance === 'exclude') return 0.3;
if (d.learning_relevance === 'peripheral') return 0.5; if (d.learning_relevance === 'peripheral') return 0.5;
return 1; return 1;
}) })
.on('click', (event, d) => { .on('click', (_event, d) => setSelectedNode(d));
setSelectedNode(d);
setIsEditing(false);
});
node.append('text') node
.append('text')
.text(d => d.label) .text(d => d.label)
.attr('x', 25) .attr('x', 25)
.attr('y', 5) .attr('y', 5)
@@ -147,100 +152,50 @@ const KnowledgeGraph = () => {
node.attr('transform', d => `translate(${d.x},${d.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(); return () => simulation.stop();
}, [dimensions, topics, relations]); }, [dimensions, topics, relations, showExcludeNodes]);
const startEdit = () => { // ── Node mutation handlers ───────────────────────────────────────────────────
setEditData({ label: selectedNode.label, type: selectedNode.type, description: selectedNode.description });
setIsEditing(true);
};
const saveEdit = async () => { const handleNodeSave = useCallback(
// If the admin touched learning_relevance, lock it so re-extraction won't overwrite the choice. async (updatedTopic) => {
// But an explicit relevance_locked in editData (the unlock checkbox) always wins. await updateTopic(updatedTopic);
const relevanceChanged = setSelectedNode(updatedTopic);
editData.learning_relevance !== undefined && },
editData.learning_relevance !== selectedNode.learning_relevance; [updateTopic],
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 () => { const handleNodeDelete = useCallback(async () => {
if (confirm(`Are you sure you want to delete "${selectedNode.label}"? This will also remove any relations connected to it.`)) { if (!selectedNode) return;
const updatedTopics = topics.filter(t => t.id !== selectedNode.id); if (
const updatedRelations = relations confirm(
.map(r => ({ `Are you sure you want to delete "${selectedNode.label}"? This will also remove any relations connected to it.`,
...r, )
source: r.source?.id || r.source, ) {
target: r.target?.id || r.target await deleteTopic(selectedNode.id);
}))
.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); setSelectedNode(null);
setIsEditing(false);
} }
}; }, [selectedNode, deleteTopic]);
const addRelation = async () => { // ── AI graph analysis ────────────────────────────────────────────────────────
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 }; const analyzeGraph = useCallback(async () => {
await db.addRelation(newRel); if (
setRelations([...relations, newRel]); !confirm(
setNewRelData({ target: '', type: 'related_to' }); '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?',
}; )
)
const removeRelation = async (sourceId, targetId, type) => { return;
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); setIsAnalyzing(true);
setAnalyzeError(null); setAnalyzeError(null);
try { try {
const currentTopics = await db.getTopics(); // Fetch fresh data so analysis reflects any concurrent edits.
const currentRelations = await db.getRelations(); 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. 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. 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.`; 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 }) => ({
const compactTopics = currentTopics.map(({ id, label, type, learning_relevance }) => ({ id, label, type, learning_relevance })); id, label, type, learning_relevance,
const compactRelations = currentRelations.map(r => ({ }));
source: r.source?.id || r.source, const compactRelations = currentRelations.map(({ source, target, type }) => ({
target: r.target?.id || r.target, source, target, type,
type: r.type
})); }));
const userPrompt = `Here is the current knowledge graph:
${JSON.stringify({ topics: compactTopics, relations: compactRelations })}`;
const llmResult = await callLLM({ const llmResult = await callLLM({
task: 'graph.analyze', task: 'graph.analyze',
tier: 'reasoning', tier: 'reasoning',
system: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }], 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], tools: [EMIT_GRAPH_ACTIONS_TOOL],
toolChoice: { type: 'tool', name: EMIT_GRAPH_ACTIONS_TOOL.name }, toolChoice: { type: 'tool', name: EMIT_GRAPH_ACTIONS_TOOL.name },
maxTokens: 4096, maxTokens: 4096,
@@ -280,69 +231,65 @@ ${JSON.stringify({ topics: compactTopics, relations: compactRelations })}`;
let updatedTopics = [...currentTopics]; let updatedTopics = [...currentTopics];
let updatedRelations = [...currentRelations]; let updatedRelations = [...currentRelations];
if (actions.merges && Array.isArray(actions.merges)) { // Apply merges: remap all edges from the deleted twin to the surviving one.
for (const merge of actions.merges) { for (const merge of actions.merges ?? []) {
updatedTopics = updatedTopics.filter(t => t.id !== merge.deleteId); updatedTopics = updatedTopics.filter(t => t.id !== merge.deleteId);
updatedRelations = updatedRelations.map(r => { 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, ...r,
source: r.source?.id || r.source, source: r.source === merge.deleteId ? merge.keepId : r.source,
target: r.target?.id || r.target target: r.target === merge.deleteId ? merge.keepId : r.target,
})) }));
.filter(r => }
r.source !== r.target &&
finalTopicIds.has(r.source) && // Apply deletions.
finalTopicIds.has(r.target) 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); // Add new relations (skip duplicates).
await db.saveRelations(updatedRelations); 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); // Apply relevance updates (skip locked topics).
setRelations(updatedRelations); 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); setSelectedNode(null);
} catch (e) { } catch (e) {
setAnalyzeError(e.message || 'Analysis failed. Please try again.'); setAnalyzeError(e.message || 'Analysis failed. Please try again.');
} finally { } finally {
setIsAnalyzing(false); setIsAnalyzing(false);
} }
}; }, [bulkSave]);
// ── Render ───────────────────────────────────────────────────────────────────
return ( return (
<div className="relative w-full h-full flex flex-col md:flex-row"> <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"> {/* D3 canvas */}
<div
ref={wrapperRef}
className="flex-1 h-[400px] md:h-full cursor-grab active:cursor-grabbing border-r border-bg-warm"
>
{topics.length === 0 ? ( {topics.length === 0 ? (
<div className="h-full flex items-center justify-center text-fg-muted p-8 text-center"> <div className="h-full flex items-center justify-center text-fg-muted p-8 text-center">
No knowledge graph data yet. Upload source material in the Sources tab first. No knowledge graph data yet. Upload source material in the Sources tab first.
@@ -352,199 +299,26 @@ ${JSON.stringify({ topics: compactTopics, relations: compactRelations })}`;
)} )}
</div> </div>
{/* Sidebar */}
<div className="w-full md:w-80 bg-paper p-6 overflow-y-auto border-t md:border-t-0 border-bg-warm flex flex-col"> <div className="w-full md:w-80 bg-paper p-6 overflow-y-auto border-t md:border-t-0 border-bg-warm flex flex-col">
<div className="mb-6 pb-4 border-b border-bg-warm space-y-3"> <GraphControls
<label className="flex items-center gap-2 text-sm text-fg-muted cursor-pointer mb-3"> showExcludeNodes={showExcludeNodes}
<input onShowExcludeChange={setShowExcludeNodes}
type="checkbox" onAnalyze={analyzeGraph}
checked={showExcludeNodes} isAnalyzing={isAnalyzing}
onChange={e => setShowExcludeNodes(e.target.checked)} analyzeError={analyzeError}
className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal" disabled={topics.length === 0}
/> onApplied={reload}
Show Excluded Nodes (Reference Material) />
</label> <NodeDetailPanel
<div> selectedNode={selectedNode}
<Button topics={topics}
onClick={analyzeGraph} relations={relations}
disabled={isAnalyzing || topics.length === 0} onSave={handleNodeSave}
className="w-full flex justify-center items-center gap-2" onDelete={handleNodeDelete}
> onAddRelation={addRelation}
<RefreshCw size={16} className={isAnalyzing ? "animate-spin" : ""} /> onRemoveRelation={removeRelation}
{isAnalyzing ? 'Analyzing Graph...' : 'Analyze & Optimize Graph'} />
</Button>
{analyzeError && (
<p className="mt-2 text-xs text-red-600 flex items-start gap-1">
<AlertCircle size={14} className="shrink-0 mt-0.5" />
{analyzeError}
</p>
)}
</div>
</div>
{/* R42 chatbot suggestions queue */}
<div className="mb-6 pb-4 border-b border-bg-warm">
<SuggestionsQueue onApplied={reloadKb} />
</div>
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-lg text-teal">Node Details</h3>
{selectedNode && !isEditing && (
<div className="flex items-center gap-1">
<button onClick={startEdit} className="p-1.5 text-fg-muted hover:text-teal rounded-[var(--r-sm)]" title="Edit">
<Edit2 size={16} />
</button>
<button onClick={deleteNode} className="p-1.5 text-fg-muted hover:text-red-500 rounded-[var(--r-sm)]" title="Delete">
<Trash2 size={16} />
</button>
</div>
)}
</div>
{selectedNode ? (
isEditing ? (
<div className="space-y-4 flex-1">
<div>
<label className="text-xs text-fg-muted uppercase tracking-wider mb-1 block">Label</label>
<input
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-3 py-1.5 text-sm bg-bg"
value={editData.label}
onChange={e => setEditData({...editData, label: e.target.value})}
/>
</div>
<div>
<label className="text-xs text-fg-muted uppercase tracking-wider mb-1 block">Type</label>
<select
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-3 py-1.5 text-sm bg-bg"
value={editData.type}
onChange={e => setEditData({...editData, type: e.target.value})}
>
<option value="concept">Concept</option>
<option value="role">Role</option>
<option value="process">Process</option>
</select>
</div>
<div>
<label className="text-xs text-fg-muted uppercase tracking-wider mb-1 block">Learning Relevance</label>
<select
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-3 py-1.5 text-sm bg-bg"
value={editData.learning_relevance || 'standard'}
onChange={e => setEditData({...editData, learning_relevance: e.target.value})}
>
<option value="core">Core</option>
<option value="standard">Standard</option>
<option value="peripheral">Peripheral</option>
<option value="exclude">Exclude</option>
</select>
{selectedNode.relevance_locked && (
<label className="flex items-center gap-2 text-xs text-fg-muted mt-2 cursor-pointer">
<input
type="checkbox"
checked={editData.relevance_locked !== false}
onChange={e => setEditData({...editData, relevance_locked: e.target.checked})}
className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal"
/>
Locked re-extraction will not change this
</label>
)}
</div>
<div>
<label className="text-xs text-fg-muted uppercase tracking-wider mb-1 block">Description</label>
<textarea
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-3 py-1.5 text-sm bg-bg min-h-[100px]"
value={editData.description}
onChange={e => setEditData({...editData, description: e.target.value})}
/>
</div>
<div className="flex gap-2 pt-2">
<Button onClick={saveEdit} className="flex-1 flex items-center justify-center gap-2"><Save size={16}/> Save</Button>
<Button variant="outline" onClick={() => setIsEditing(false)} className="flex-1 flex items-center justify-center gap-2"><X size={16}/> Cancel</Button>
</div>
</div>
) : (
<div className="space-y-4 flex-1">
<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 & Relevance</p>
<div className="flex gap-2 flex-wrap">
<span className="inline-block px-2 py-1 bg-bg-warm rounded-[var(--r-pill)] text-xs font-mono">{selectedNode.type}</span>
<span className="inline-block px-2 py-1 bg-bg-warm rounded-[var(--r-pill)] text-xs font-mono opacity-80">{selectedNode.learning_relevance || 'standard'}</span>
{selectedNode.relevance_locked && (
<span className="inline-block px-2 py-1 bg-teal/10 text-teal rounded-[var(--r-pill)] text-xs font-mono" title="Re-extraction will not change relevance for this topic.">locked</span>
)}
</div>
</div>
<div>
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Description</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 className="pt-4 border-t border-bg-warm">
<p className="text-xs text-fg-muted uppercase tracking-wider mb-3">Relations</p>
<div className="space-y-2 mb-4">
{relations.filter(r => (r.source.id || r.source) === selectedNode.id).map((rel, idx) => {
const targetId = rel.target.id || rel.target;
const targetTopic = topics.find(t => t.id === targetId);
return (
<div key={idx} className="flex items-center justify-between bg-bg rounded-[var(--r-sm)] p-2 border border-bg-warm">
<div className="text-xs">
<span className="font-mono text-teal">{rel.type}</span> &rarr; {targetTopic ? targetTopic.label : targetId}
</div>
<button
onClick={() => removeRelation(selectedNode.id, targetId, rel.type)}
className="text-fg-muted hover:text-red-500 p-1"
title="Remove Relation"
>
<X size={14} />
</button>
</div>
);
})}
</div>
<div className="bg-bg rounded-[var(--r-sm)] p-3 border border-bg-warm space-y-2">
<p className="text-xs font-medium flex items-center gap-1"><LinkIcon size={12}/> Add Relation</p>
<select
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-2 py-1 text-xs bg-paper"
value={newRelData.type}
onChange={e => setNewRelData({...newRelData, type: e.target.value})}
>
<option value="related_to">related_to</option>
<option value="depends_on">depends_on</option>
<option value="part_of">part_of</option>
<option value="executed_by">executed_by</option>
</select>
<select
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-2 py-1 text-xs bg-paper"
value={newRelData.target}
onChange={e => setNewRelData({...newRelData, target: e.target.value})}
>
<option value="">-- Select Target Topic --</option>
{topics.filter(t => t.id !== selectedNode.id).map(t => (
<option key={t.id} value={t.id}>{t.label}</option>
))}
</select>
<Button
onClick={addRelation}
disabled={!newRelData.target}
className="w-full py-1 text-xs mt-1"
>
<Plus size={14} className="mr-1 inline" /> Add
</Button>
</div>
</div>
</div>
)
) : (
<p className="text-sm text-fg-muted">Click a node in the graph to view or edit its details.</p>
)}
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,290 @@
import { useState, useEffect } from 'react';
import { Trash2, Edit2, Save, X, Plus, Link as LinkIcon } from 'lucide-react';
import Button from '../../ui/Button';
const RELATION_TYPES = ['related_to', 'depends_on', 'part_of', 'executed_by'];
/**
* Right-panel node detail section.
*
* Owns only its own UI state (edit mode, form drafts).
* All persistence is delegated back to the parent via callbacks so this
* component stays pure-UI and trivially testable.
*
* Props:
* selectedNode — the currently selected topic object, or null
* topics — full topic list (used for the relation-target selector)
* relations — full relation list (to show outgoing edges)
* onSave(topic) — called with the fully-merged updated topic
* onDelete() — called when the admin confirms deletion
* onAddRelation(rel) — called with { source, target, type }
* onRemoveRelation(s,t,type) — called to drop a specific edge
*/
export default function NodeDetailPanel({
selectedNode,
topics,
relations,
onSave,
onDelete,
onAddRelation,
onRemoveRelation,
}) {
const [isEditing, setIsEditing] = useState(false);
const [editData, setEditData] = useState({});
const [newRelData, setNewRelData] = useState({ target: '', type: 'related_to' });
// Reset local state whenever the selected node changes.
useEffect(() => {
setIsEditing(false);
setEditData({});
setNewRelData({ target: '', type: 'related_to' });
}, [selectedNode?.id]);
const startEdit = () => {
setEditData({
label: selectedNode.label,
type: selectedNode.type,
description: selectedNode.description ?? '',
learning_relevance: selectedNode.learning_relevance || 'standard',
relevance_locked: selectedNode.relevance_locked,
});
setIsEditing(true);
};
const handleSave = async () => {
// Auto-lock relevance whenever the admin changes it manually.
const relevanceChanged = editData.learning_relevance !== selectedNode.learning_relevance;
const next = { ...editData };
if (relevanceChanged && next.relevance_locked === undefined) next.relevance_locked = true;
await onSave({ ...selectedNode, ...next });
setIsEditing(false);
};
const handleAddRelation = async () => {
if (!newRelData.target) return;
await onAddRelation({ source: selectedNode.id, target: newRelData.target, type: newRelData.type });
setNewRelData({ target: '', type: 'related_to' });
};
const outgoing = relations.filter(r => r.source === selectedNode?.id);
return (
<div className="flex-1">
{/* Header — always visible */}
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-lg text-teal">Node Details</h3>
{selectedNode && !isEditing && (
<div className="flex items-center gap-1">
<button
onClick={startEdit}
className="p-1.5 text-fg-muted hover:text-teal rounded-[var(--r-sm)]"
title="Edit"
>
<Edit2 size={16} />
</button>
<button
onClick={onDelete}
className="p-1.5 text-fg-muted hover:text-red-500 rounded-[var(--r-sm)]"
title="Delete"
>
<Trash2 size={16} />
</button>
</div>
)}
</div>
{/* Empty state */}
{!selectedNode && (
<p className="text-sm text-fg-muted">
Click a node in the graph to view or edit its details.
</p>
)}
{/* Edit mode */}
{selectedNode && isEditing && (
<div className="space-y-4">
<div>
<label className="text-xs text-fg-muted uppercase tracking-wider mb-1 block">Label</label>
<input
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-3 py-1.5 text-sm bg-bg"
value={editData.label ?? ''}
onChange={e => setEditData({ ...editData, label: e.target.value })}
/>
</div>
<div>
<label className="text-xs text-fg-muted uppercase tracking-wider mb-1 block">Type</label>
<select
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-3 py-1.5 text-sm bg-bg"
value={editData.type ?? 'concept'}
onChange={e => setEditData({ ...editData, type: e.target.value })}
>
<option value="concept">Concept</option>
<option value="role">Role</option>
<option value="process">Process</option>
</select>
</div>
<div>
<label className="text-xs text-fg-muted uppercase tracking-wider mb-1 block">
Learning Relevance
</label>
<select
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-3 py-1.5 text-sm bg-bg"
value={editData.learning_relevance ?? 'standard'}
onChange={e => setEditData({ ...editData, learning_relevance: e.target.value })}
>
<option value="core">Core</option>
<option value="standard">Standard</option>
<option value="peripheral">Peripheral</option>
<option value="exclude">Exclude</option>
</select>
{selectedNode.relevance_locked && (
<label className="flex items-center gap-2 text-xs text-fg-muted mt-2 cursor-pointer">
<input
type="checkbox"
checked={editData.relevance_locked !== false}
onChange={e => setEditData({ ...editData, relevance_locked: e.target.checked })}
className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal"
/>
Locked re-extraction will not change this
</label>
)}
</div>
<div>
<label className="text-xs text-fg-muted uppercase tracking-wider mb-1 block">
Description
</label>
<textarea
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-3 py-1.5 text-sm bg-bg min-h-[100px]"
value={editData.description ?? ''}
onChange={e => setEditData({ ...editData, description: e.target.value })}
/>
</div>
<div className="flex gap-2 pt-2">
<Button onClick={handleSave} className="flex-1 flex items-center justify-center gap-2">
<Save size={16} /> Save
</Button>
<Button
variant="outline"
onClick={() => setIsEditing(false)}
className="flex-1 flex items-center justify-center gap-2"
>
<X size={16} /> Cancel
</Button>
</div>
</div>
)}
{/* View mode */}
{selectedNode && !isEditing && (
<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 & Relevance</p>
<div className="flex gap-2 flex-wrap">
<span className="inline-block px-2 py-1 bg-bg-warm rounded-[var(--r-pill)] text-xs font-mono">
{selectedNode.type}
</span>
<span className="inline-block px-2 py-1 bg-bg-warm rounded-[var(--r-pill)] text-xs font-mono opacity-80">
{selectedNode.learning_relevance || 'standard'}
</span>
{selectedNode.relevance_locked && (
<span
className="inline-block px-2 py-1 bg-teal/10 text-teal rounded-[var(--r-pill)] text-xs font-mono"
title="Re-extraction will not change relevance for this topic."
>
locked
</span>
)}
</div>
</div>
<div>
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Description</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>
{/* Relations ───────────────────────────────────────────────────── */}
<div className="pt-4 border-t border-bg-warm">
<p className="text-xs text-fg-muted uppercase tracking-wider mb-3">Relations</p>
<div className="space-y-2 mb-4">
{outgoing.length === 0 && (
<p className="text-xs text-fg-muted italic">No outgoing relations.</p>
)}
{outgoing.map((rel, idx) => {
const target = topics.find(t => t.id === rel.target);
return (
<div
key={idx}
className="flex items-center justify-between bg-bg rounded-[var(--r-sm)] p-2 border border-bg-warm"
>
<div className="text-xs min-w-0 mr-2">
<span className="font-mono text-teal">{rel.type}</span>
{' → '}
<span className="truncate">{target ? target.label : rel.target}</span>
</div>
<button
onClick={() => onRemoveRelation(selectedNode.id, rel.target, rel.type)}
className="shrink-0 text-fg-muted hover:text-red-500 p-1"
title="Remove relation"
>
<X size={14} />
</button>
</div>
);
})}
</div>
{/* Add relation ────────────────────────────────────────────── */}
<div className="bg-bg rounded-[var(--r-sm)] p-3 border border-bg-warm space-y-2">
<p className="text-xs font-medium flex items-center gap-1">
<LinkIcon size={12} /> Add Relation
</p>
<select
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-2 py-1 text-xs bg-paper"
value={newRelData.type}
onChange={e => setNewRelData({ ...newRelData, type: e.target.value })}
>
{RELATION_TYPES.map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>
<select
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-2 py-1 text-xs bg-paper"
value={newRelData.target}
onChange={e => setNewRelData({ ...newRelData, target: e.target.value })}
>
<option value=""> Select target topic </option>
{topics
.filter(t => t.id !== selectedNode.id)
.map(t => (
<option key={t.id} value={t.id}>{t.label}</option>
))}
</select>
<Button
onClick={handleAddRelation}
disabled={!newRelData.target}
className="w-full py-1 text-xs mt-1"
>
<Plus size={14} className="mr-1 inline" /> Add
</Button>
</div>
</div>
</div>
)}
</div>
);
}