feat: refactor KnowledgeGraph component and add NodeDetailPanel for enhanced node management
This commit is contained in:
@@ -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 (
|
||||
<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 ? (
|
||||
<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.
|
||||
@@ -352,199 +299,26 @@ ${JSON.stringify({ topics: compactTopics, relations: compactRelations })}`;
|
||||
)}
|
||||
</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="mb-6 pb-4 border-b border-bg-warm space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm text-fg-muted cursor-pointer mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showExcludeNodes}
|
||||
onChange={e => setShowExcludeNodes(e.target.checked)}
|
||||
className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal"
|
||||
/>
|
||||
Show Excluded Nodes (Reference Material)
|
||||
</label>
|
||||
<div>
|
||||
<Button
|
||||
onClick={analyzeGraph}
|
||||
disabled={isAnalyzing || topics.length === 0}
|
||||
className="w-full flex justify-center items-center gap-2"
|
||||
>
|
||||
<RefreshCw size={16} className={isAnalyzing ? "animate-spin" : ""} />
|
||||
{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> → {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>
|
||||
)}
|
||||
<GraphControls
|
||||
showExcludeNodes={showExcludeNodes}
|
||||
onShowExcludeChange={setShowExcludeNodes}
|
||||
onAnalyze={analyzeGraph}
|
||||
isAnalyzing={isAnalyzing}
|
||||
analyzeError={analyzeError}
|
||||
disabled={topics.length === 0}
|
||||
onApplied={reload}
|
||||
/>
|
||||
<NodeDetailPanel
|
||||
selectedNode={selectedNode}
|
||||
topics={topics}
|
||||
relations={relations}
|
||||
onSave={handleNodeSave}
|
||||
onDelete={handleNodeDelete}
|
||||
onAddRelation={addRelation}
|
||||
onRemoveRelation={removeRelation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
290
src/components/admin/graph/NodeDetailPanel.jsx
Normal file
290
src/components/admin/graph/NodeDetailPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user