feat: enhance KnowledgeGraph with edge styles, node navigation, and AI analysis scopes; update GraphControls and NodeDetailPanel for improved relation management
This commit is contained in:
@@ -7,18 +7,64 @@ import { useGraphData } from '../../hooks/useGraphData';
|
|||||||
import GraphControls from './graph/GraphControls';
|
import GraphControls from './graph/GraphControls';
|
||||||
import NodeDetailPanel from './graph/NodeDetailPanel';
|
import NodeDetailPanel from './graph/NodeDetailPanel';
|
||||||
|
|
||||||
|
// ── Edge visual style per relation type ────────────────────────────────────────
|
||||||
|
// stroke — line colour
|
||||||
|
// dash — SVG stroke-dasharray (null = solid)
|
||||||
|
const EDGE_STYLE = {
|
||||||
|
related_to: { stroke: '#94A3B8', dash: '6,3' }, // slate, dashed
|
||||||
|
depends_on: { stroke: '#F97316', dash: null }, // orange, solid
|
||||||
|
part_of: { stroke: '#10B981', dash: null }, // emerald, solid
|
||||||
|
executed_by: { stroke: '#C084FC', dash: '3,3' }, // violet, dotted
|
||||||
|
};
|
||||||
|
const NODE_RADIUS = 22; // circle r (20) + white stroke buffer
|
||||||
|
|
||||||
|
// ── System prompts per analysis scope ─────────────────────────────────────────
|
||||||
|
const SYSTEM_PROMPTS = {
|
||||||
|
full: `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.`,
|
||||||
|
|
||||||
|
relations: `You are analyzing a knowledge graph for Respellion's learning platform.
|
||||||
|
Your ONLY task: identify missing logical relations between existing topics.
|
||||||
|
Suggest new relations using: depends_on, part_of, related_to, executed_by.
|
||||||
|
Only suggest connections that are clearly implied — do not invent loose associations.
|
||||||
|
Emit ONLY newRelations. Set merges=[], deletions=[], relevanceUpdates=[].`,
|
||||||
|
|
||||||
|
relevance: `You are analyzing a knowledge graph for Respellion's learning platform.
|
||||||
|
Your ONLY task: re-score the learning_relevance for each topic.
|
||||||
|
Relevance levels:
|
||||||
|
core — foundational knowledge every employee must master
|
||||||
|
standard — important, covered in the normal learning flow
|
||||||
|
peripheral — supplementary or nice-to-know, lower priority
|
||||||
|
exclude — operational/administrative, not relevant to learning (e.g. printer guides, HR forms)
|
||||||
|
Do NOT change topics where relevance_locked=true — omit them from relevanceUpdates entirely.
|
||||||
|
Emit ONLY relevanceUpdates. Set merges=[], deletions=[], newRelations=[].`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCOPE_CONFIRM = {
|
||||||
|
full: 'Send the entire graph to the AI (Opus) for a full quality pass — merges, deletions, missing relations, and relevance scoring. This may take a minute.',
|
||||||
|
relations: 'Ask the AI (Sonnet) to suggest missing logical relations between existing topics.',
|
||||||
|
relevance: 'Ask the AI (Sonnet) to re-score learning relevance for all unlocked topics.',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Knowledge-graph admin view.
|
* Knowledge-graph admin view.
|
||||||
*
|
*
|
||||||
* This component is the orchestrator: it owns the D3 canvas, the selected-node
|
* Orchestrator: owns the D3 canvas, selected-node cursor, and AI analysis flow.
|
||||||
* cursor, and the AI analysis flow. All data fetching lives in useGraphData;
|
* Data lives in useGraphData; panel UI lives in GraphControls / NodeDetailPanel.
|
||||||
* 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 zoomRef = useRef(null); // D3 zoom behaviour — shared with panToNode
|
||||||
|
const nodesRef = useRef([]); // live node positions (mutated by D3 in-place)
|
||||||
|
|
||||||
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);
|
||||||
@@ -61,9 +107,10 @@ const KnowledgeGraph = () => {
|
|||||||
r => filteredIds.has(r.source) && filteredIds.has(r.target),
|
r => filteredIds.has(r.source) && filteredIds.has(r.target),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Spread every node/link so D3's simulation mutations don't touch React state.
|
// Spread every datum so D3 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 }));
|
||||||
|
nodesRef.current = nodes; // D3 mutates x/y in-place — ref always has latest positions
|
||||||
|
|
||||||
const svg = d3
|
const svg = d3
|
||||||
.select(svgRef.current)
|
.select(svgRef.current)
|
||||||
@@ -71,13 +118,31 @@ const KnowledgeGraph = () => {
|
|||||||
.attr('width', width)
|
.attr('width', width)
|
||||||
.attr('height', height);
|
.attr('height', height);
|
||||||
|
|
||||||
|
// ── Arrowhead markers — one per relation type ──────────────────────────────
|
||||||
|
const defs = svg.append('defs');
|
||||||
|
Object.entries(EDGE_STYLE).forEach(([type, style]) => {
|
||||||
|
defs.append('marker')
|
||||||
|
.attr('id', `kb-arrow-${type}`)
|
||||||
|
.attr('viewBox', '0 -5 10 10')
|
||||||
|
.attr('refX', 10) // tip of arrow at line endpoint
|
||||||
|
.attr('refY', 0)
|
||||||
|
.attr('markerWidth', 5)
|
||||||
|
.attr('markerHeight', 5)
|
||||||
|
.attr('orient', 'auto')
|
||||||
|
.append('path')
|
||||||
|
.attr('d', 'M0,-5L10,0L0,5Z')
|
||||||
|
.attr('fill', style.stroke)
|
||||||
|
.attr('opacity', 0.85);
|
||||||
|
});
|
||||||
|
|
||||||
const g = svg.append('g');
|
const g = svg.append('g');
|
||||||
|
|
||||||
svg.call(
|
const zoom = d3.zoom()
|
||||||
d3.zoom()
|
.scaleExtent([0.1, 4])
|
||||||
.scaleExtent([0.1, 4])
|
.on('zoom', event => g.attr('transform', event.transform));
|
||||||
.on('zoom', event => g.attr('transform', event.transform)),
|
|
||||||
);
|
svg.call(zoom);
|
||||||
|
zoomRef.current = zoom; // expose to panToNode
|
||||||
|
|
||||||
const color = d3
|
const color = d3
|
||||||
.scaleOrdinal()
|
.scaleOrdinal()
|
||||||
@@ -91,15 +156,19 @@ const KnowledgeGraph = () => {
|
|||||||
.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));
|
||||||
|
|
||||||
|
// ── Edges — coloured and typed ─────────────────────────────────────────────
|
||||||
const link = g
|
const link = g
|
||||||
.append('g')
|
.append('g')
|
||||||
.attr('stroke', 'var(--color-bg-warm)')
|
|
||||||
.attr('stroke-opacity', 0.6)
|
|
||||||
.selectAll('line')
|
.selectAll('line')
|
||||||
.data(links)
|
.data(links)
|
||||||
.join('line')
|
.join('line')
|
||||||
.attr('stroke-width', 2);
|
.attr('stroke', d => EDGE_STYLE[d.type]?.stroke ?? '#94A3B8')
|
||||||
|
.attr('stroke-opacity', 0.7)
|
||||||
|
.attr('stroke-width', 1.5)
|
||||||
|
.attr('stroke-dasharray', d => EDGE_STYLE[d.type]?.dash ?? null)
|
||||||
|
.attr('marker-end', d => `url(#kb-arrow-${d.type})`);
|
||||||
|
|
||||||
|
// ── Nodes ──────────────────────────────────────────────────────────────────
|
||||||
const node = g
|
const node = g
|
||||||
.append('g')
|
.append('g')
|
||||||
.selectAll('g')
|
.selectAll('g')
|
||||||
@@ -107,16 +176,16 @@ const KnowledgeGraph = () => {
|
|||||||
.join('g')
|
.join('g')
|
||||||
.call(
|
.call(
|
||||||
d3.drag()
|
d3.drag()
|
||||||
.on('start', (event) => {
|
.on('start', event => {
|
||||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||||
event.subject.fx = event.subject.x;
|
event.subject.fx = event.subject.x;
|
||||||
event.subject.fy = event.subject.y;
|
event.subject.fy = event.subject.y;
|
||||||
})
|
})
|
||||||
.on('drag', (event) => {
|
.on('drag', event => {
|
||||||
event.subject.fx = event.x;
|
event.subject.fx = event.x;
|
||||||
event.subject.fy = event.y;
|
event.subject.fy = event.y;
|
||||||
})
|
})
|
||||||
.on('end', (event) => {
|
.on('end', event => {
|
||||||
if (!event.active) simulation.alphaTarget(0);
|
if (!event.active) simulation.alphaTarget(0);
|
||||||
event.subject.fx = null;
|
event.subject.fx = null;
|
||||||
event.subject.fy = null;
|
event.subject.fy = null;
|
||||||
@@ -146,35 +215,65 @@ const KnowledgeGraph = () => {
|
|||||||
.attr('fill', 'var(--color-fg)')
|
.attr('fill', 'var(--color-fg)')
|
||||||
.attr('class', 'font-mono select-none pointer-events-none');
|
.attr('class', 'font-mono select-none pointer-events-none');
|
||||||
|
|
||||||
|
// ── Tick — offset line endpoints to circle edge so arrows land cleanly ─────
|
||||||
simulation.on('tick', () => {
|
simulation.on('tick', () => {
|
||||||
link
|
link
|
||||||
.attr('x1', d => d.source.x)
|
.attr('x1', d => d.source.x)
|
||||||
.attr('y1', d => d.source.y)
|
.attr('y1', d => d.source.y)
|
||||||
.attr('x2', d => d.target.x)
|
.attr('x2', d => {
|
||||||
.attr('y2', d => d.target.y);
|
const dx = d.target.x - d.source.x;
|
||||||
|
const dy = d.target.y - d.source.y;
|
||||||
|
const dist = Math.hypot(dx, dy) || 1;
|
||||||
|
return d.target.x - (dx / dist) * NODE_RADIUS;
|
||||||
|
})
|
||||||
|
.attr('y2', d => {
|
||||||
|
const dx = d.target.x - d.source.x;
|
||||||
|
const dy = d.target.y - d.source.y;
|
||||||
|
const dist = Math.hypot(dx, dy) || 1;
|
||||||
|
return d.target.y - (dy / dist) * NODE_RADIUS;
|
||||||
|
});
|
||||||
node.attr('transform', d => `translate(${d.x},${d.y})`);
|
node.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => simulation.stop();
|
return () => simulation.stop();
|
||||||
}, [dimensions, topics, relations, showExcludeNodes]);
|
}, [dimensions, topics, relations, showExcludeNodes]);
|
||||||
|
|
||||||
|
// ── Pan/zoom canvas to a node ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const panToNode = useCallback((nodeId) => {
|
||||||
|
const n = nodesRef.current.find(nd => nd.id === nodeId);
|
||||||
|
if (!n || !svgRef.current || !zoomRef.current) return;
|
||||||
|
const { width, height } = dimensions;
|
||||||
|
const scale = 2;
|
||||||
|
d3.select(svgRef.current)
|
||||||
|
.transition()
|
||||||
|
.duration(500)
|
||||||
|
.call(
|
||||||
|
zoomRef.current.transform,
|
||||||
|
d3.zoomIdentity
|
||||||
|
.translate(width / 2 - n.x * scale, height / 2 - n.y * scale)
|
||||||
|
.scale(scale),
|
||||||
|
);
|
||||||
|
}, [dimensions]);
|
||||||
|
|
||||||
|
// ── Node navigation (jump from relation row) ─────────────────────────────────
|
||||||
|
|
||||||
|
const handleJumpToNode = useCallback((nodeId) => {
|
||||||
|
panToNode(nodeId);
|
||||||
|
const topic = topics.find(t => t.id === nodeId);
|
||||||
|
if (topic) setSelectedNode(topic);
|
||||||
|
}, [panToNode, topics]);
|
||||||
|
|
||||||
// ── Node mutation handlers ───────────────────────────────────────────────────
|
// ── Node mutation handlers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
const handleNodeSave = useCallback(
|
const handleNodeSave = useCallback(async (updatedTopic) => {
|
||||||
async (updatedTopic) => {
|
await updateTopic(updatedTopic);
|
||||||
await updateTopic(updatedTopic);
|
setSelectedNode(updatedTopic);
|
||||||
setSelectedNode(updatedTopic);
|
}, [updateTopic]);
|
||||||
},
|
|
||||||
[updateTopic],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleNodeDelete = useCallback(async () => {
|
const handleNodeDelete = useCallback(async () => {
|
||||||
if (!selectedNode) return;
|
if (!selectedNode) return;
|
||||||
if (
|
if (confirm(`Are you sure you want to delete "${selectedNode.label}"? This will also remove any relations connected to it.`)) {
|
||||||
confirm(
|
|
||||||
`Are you sure you want to delete "${selectedNode.label}"? This will also remove any relations connected to it.`,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
await deleteTopic(selectedNode.id);
|
await deleteTopic(selectedNode.id);
|
||||||
setSelectedNode(null);
|
setSelectedNode(null);
|
||||||
}
|
}
|
||||||
@@ -183,12 +282,9 @@ const KnowledgeGraph = () => {
|
|||||||
// ── Snapshot restore ─────────────────────────────────────────────────────────
|
// ── Snapshot restore ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const handleRestore = useCallback(async () => {
|
const handleRestore = useCallback(async () => {
|
||||||
if (
|
if (!confirm(
|
||||||
!confirm(
|
`Restore the graph to the snapshot from ${new Date(snapshotMeta?.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}?\n\nThis will undo the last Analyze run. The snapshot will be cleared after restoring.`,
|
||||||
`Restore the graph to the snapshot from ${new Date(snapshotMeta?.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}?\n\nThis will undo the last Analyze & Optimize run. The snapshot will be cleared after restoring.`,
|
)) return;
|
||||||
)
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
setIsRestoring(true);
|
setIsRestoring(true);
|
||||||
try {
|
try {
|
||||||
await restoreSnapshot();
|
await restoreSnapshot();
|
||||||
@@ -198,52 +294,42 @@ const KnowledgeGraph = () => {
|
|||||||
}
|
}
|
||||||
}, [restoreSnapshot, snapshotMeta]);
|
}, [restoreSnapshot, snapshotMeta]);
|
||||||
|
|
||||||
// ── AI graph analysis ────────────────────────────────────────────────────────
|
// ── AI graph analysis (scoped) ───────────────────────────────────────────────
|
||||||
|
|
||||||
const analyzeGraph = useCallback(async () => {
|
const analyzeGraph = useCallback(async (scope = 'full') => {
|
||||||
if (
|
if (!confirm(SCOPE_CONFIRM[scope])) return;
|
||||||
!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 {
|
||||||
// Fetch fresh data so analysis reflects any concurrent edits.
|
|
||||||
const [currentTopics, currentRelations] = await Promise.all([
|
const [currentTopics, currentRelations] = await Promise.all([
|
||||||
db.getTopics(),
|
db.getTopics(),
|
||||||
db.getRelations(),
|
db.getRelations(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const systemPrompt = `You are a strict Data Quality AI maintaining a Knowledge Graph for Respellion.
|
const tier = scope === 'full' ? 'reasoning' : 'standard';
|
||||||
Evaluate the provided topics and relations and emit the actions to take via the emit_graph_actions tool.
|
|
||||||
|
|
||||||
Rules:
|
// For relevance scope, include relevance_locked so the model can skip those.
|
||||||
1. Identify topics that mean exactly the same thing. Choose one to keep, one to delete (merges).
|
const compactTopics = currentTopics.map(t => ({
|
||||||
2. Identify topics that are too vague, irrelevant, or malformed (deletions).
|
id: t.id,
|
||||||
3. Identify missing logical relations (depends_on, part_of, related_to, executed_by) between conceptually linked topics (newRelations).
|
label: t.label,
|
||||||
4. Evaluate learning_relevance. Mark purely operational topics (printer guides, etc.) as "exclude"; low-priority as "peripheral" (relevanceUpdates).
|
type: t.type,
|
||||||
|
learning_relevance: t.learning_relevance,
|
||||||
Do not return the entire graph — only the actions to take.`;
|
...(scope === 'relevance' && t.relevance_locked ? { relevance_locked: true } : {}),
|
||||||
|
|
||||||
const compactTopics = currentTopics.map(({ id, label, type, learning_relevance }) => ({
|
|
||||||
id, label, type, learning_relevance,
|
|
||||||
}));
|
}));
|
||||||
const compactRelations = currentRelations.map(({ source, target, type }) => ({
|
const compactRelations = currentRelations.map(({ source, target, type }) => ({
|
||||||
source, target, type,
|
source, target, type,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const llmResult = await callLLM({
|
const llmResult = await callLLM({
|
||||||
task: 'graph.analyze',
|
task: `graph.analyze.${scope}`,
|
||||||
tier: 'reasoning',
|
tier,
|
||||||
system: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }],
|
system: [{ type: 'text', text: SYSTEM_PROMPTS[scope], cache_control: { type: 'ephemeral' } }],
|
||||||
user: `Here is the current knowledge graph:\n${JSON.stringify({ topics: compactTopics, relations: compactRelations })}`,
|
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: scope === 'full' ? 4096 : 2048,
|
||||||
});
|
});
|
||||||
|
|
||||||
const actions = llmResult.toolUses[0]?.input;
|
const actions = llmResult.toolUses[0]?.input;
|
||||||
@@ -252,42 +338,46 @@ Do not return the entire graph — only the actions to take.`;
|
|||||||
let updatedTopics = [...currentTopics];
|
let updatedTopics = [...currentTopics];
|
||||||
let updatedRelations = [...currentRelations];
|
let updatedRelations = [...currentRelations];
|
||||||
|
|
||||||
// Apply merges: remap all edges from the deleted twin to the surviving one.
|
// Full scope: merges + deletions
|
||||||
for (const merge of actions.merges ?? []) {
|
if (scope === 'full') {
|
||||||
updatedTopics = updatedTopics.filter(t => t.id !== merge.deleteId);
|
for (const merge of actions.merges ?? []) {
|
||||||
updatedRelations = updatedRelations.map(r => ({
|
updatedTopics = updatedTopics.filter(t => t.id !== merge.deleteId);
|
||||||
...r,
|
updatedRelations = updatedRelations.map(r => ({
|
||||||
source: r.source === merge.deleteId ? merge.keepId : r.source,
|
...r,
|
||||||
target: r.target === merge.deleteId ? merge.keepId : 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) {
|
||||||
if (actions.deletions?.length) {
|
const toDelete = new Set(actions.deletions);
|
||||||
const toDelete = new Set(actions.deletions);
|
updatedTopics = updatedTopics.filter(t => !toDelete.has(t.id));
|
||||||
updatedTopics = updatedTopics.filter(t => !toDelete.has(t.id));
|
updatedRelations = updatedRelations.filter(
|
||||||
updatedRelations = updatedRelations.filter(
|
r => !toDelete.has(r.source) && !toDelete.has(r.target),
|
||||||
r => !toDelete.has(r.source) && !toDelete.has(r.target),
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
// Full + relations scope: new relations
|
||||||
|
if (scope === 'full' || scope === 'relations') {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full + relevance scope: relevance updates (respect locked topics)
|
||||||
|
if (scope === 'full' || scope === 'relevance') {
|
||||||
|
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 edges that now reference non-existent nodes or are self-loops.
|
||||||
const finalIds = new Set(updatedTopics.map(t => t.id));
|
const finalIds = new Set(updatedTopics.map(t => t.id));
|
||||||
updatedRelations = updatedRelations.filter(
|
updatedRelations = updatedRelations.filter(
|
||||||
r => r.source !== r.target && finalIds.has(r.source) && finalIds.has(r.target),
|
r => r.source !== r.target && finalIds.has(r.source) && finalIds.has(r.target),
|
||||||
@@ -342,6 +432,7 @@ Do not return the entire graph — only the actions to take.`;
|
|||||||
onDelete={handleNodeDelete}
|
onDelete={handleNodeDelete}
|
||||||
onAddRelation={addRelation}
|
onAddRelation={addRelation}
|
||||||
onRemoveRelation={removeRelation}
|
onRemoveRelation={removeRelation}
|
||||||
|
onJumpToNode={handleJumpToNode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
import { RotateCcw, RefreshCw, AlertCircle } from 'lucide-react';
|
import { useState } from 'react';
|
||||||
|
import { RotateCcw, RefreshCw, AlertCircle, ChevronDown } from 'lucide-react';
|
||||||
import Button from '../../ui/Button';
|
import Button from '../../ui/Button';
|
||||||
import SuggestionsQueue from '../SuggestionsQueue';
|
import SuggestionsQueue from '../SuggestionsQueue';
|
||||||
|
|
||||||
|
const SCOPE_OPTIONS = [
|
||||||
|
{ value: 'full', label: 'Full Analysis', tier: 'Opus', hint: 'Merges · Deletions · Relations · Relevance' },
|
||||||
|
{ value: 'relations', label: 'Relations Only', tier: 'Sonnet', hint: 'Suggest missing logical connections' },
|
||||||
|
{ value: 'relevance', label: 'Relevance Only', tier: 'Sonnet', hint: 'Re-score learning_relevance for all unlocked topics' },
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sidebar controls panel: the exclude-nodes toggle, the AI analysis button,
|
* Sidebar controls panel: the exclude-nodes toggle, the AI analysis button
|
||||||
* the snapshot restore row, and the R42 suggestions queue.
|
* (with scope selector), the snapshot restore row, and the R42 suggestions queue.
|
||||||
*
|
*
|
||||||
* Kept intentionally thin — all async logic lives in the KnowledgeGraph
|
* Kept intentionally thin — all async logic lives in the KnowledgeGraph
|
||||||
* orchestrator and is injected via props.
|
* orchestrator and is injected via props.
|
||||||
|
*
|
||||||
|
* The scope selector is local state here; it produces a scope string that is
|
||||||
|
* forwarded to onAnalyze(scope). Scope options map to different AI tiers:
|
||||||
|
* full → reasoning (Opus) — expensive full quality pass
|
||||||
|
* relations / relevance → standard (Sonnet) — targeted cheaper sub-task
|
||||||
*/
|
*/
|
||||||
export default function GraphControls({
|
export default function GraphControls({
|
||||||
showExcludeNodes,
|
showExcludeNodes,
|
||||||
@@ -21,6 +33,10 @@ export default function GraphControls({
|
|||||||
onRestore,
|
onRestore,
|
||||||
isRestoring,
|
isRestoring,
|
||||||
}) {
|
}) {
|
||||||
|
const [scope, setScope] = useState('full');
|
||||||
|
|
||||||
|
const selectedOption = SCOPE_OPTIONS.find(o => o.value === scope) ?? SCOPE_OPTIONS[0];
|
||||||
|
|
||||||
const snapshotLabel = snapshotMeta
|
const snapshotLabel = snapshotMeta
|
||||||
? new Date(snapshotMeta.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
? new Date(snapshotMeta.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
: null;
|
: null;
|
||||||
@@ -38,13 +54,35 @@ export default function GraphControls({
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
{/* Scope selector */}
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={scope}
|
||||||
|
onChange={e => setScope(e.target.value)}
|
||||||
|
disabled={isAnalyzing || isRestoring || disabled}
|
||||||
|
className="w-full border border-bg-warm rounded-[var(--r-sm)] pl-3 pr-8 py-1.5 text-xs bg-paper appearance-none disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
title={selectedOption.hint}
|
||||||
|
>
|
||||||
|
{SCOPE_OPTIONS.map(o => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label} — {o.tier}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown
|
||||||
|
size={12}
|
||||||
|
className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-fg-muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-fg-muted leading-snug">{selectedOption.hint}</p>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={onAnalyze}
|
onClick={() => onAnalyze(scope)}
|
||||||
disabled={isAnalyzing || isRestoring || disabled}
|
disabled={isAnalyzing || isRestoring || disabled}
|
||||||
className="w-full flex justify-center items-center gap-2"
|
className="w-full flex justify-center items-center gap-2"
|
||||||
>
|
>
|
||||||
<RefreshCw size={16} className={isAnalyzing ? 'animate-spin' : ''} />
|
<RefreshCw size={16} className={isAnalyzing ? 'animate-spin' : ''} />
|
||||||
{isAnalyzing ? 'Analyzing Graph…' : 'Analyze & Optimize Graph'}
|
{isAnalyzing ? 'Analyzing…' : 'Analyze & Optimize Graph'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Restore row — only visible after a bulkSave has written a snapshot */}
|
{/* Restore row — only visible after a bulkSave has written a snapshot */}
|
||||||
@@ -58,7 +96,7 @@ export default function GraphControls({
|
|||||||
<RotateCcw size={12} className={isRestoring ? 'animate-spin' : ''} />
|
<RotateCcw size={12} className={isRestoring ? 'animate-spin' : ''} />
|
||||||
{isRestoring
|
{isRestoring
|
||||||
? 'Restoring…'
|
? 'Restoring…'
|
||||||
: `Restore to ${snapshotLabel} · ${snapshotMeta.topicCount}t ${snapshotMeta.relationCount}r`}
|
: `↩ Restore to ${snapshotLabel} · ${snapshotMeta.topicCount}t ${snapshotMeta.relationCount}r`}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Trash2, Edit2, Save, X, Plus, Link as LinkIcon } from 'lucide-react';
|
import { Trash2, Edit2, Save, X, Plus, Link as LinkIcon, ArrowUpRight } from 'lucide-react';
|
||||||
import Button from '../../ui/Button';
|
import Button from '../../ui/Button';
|
||||||
|
|
||||||
const RELATION_TYPES = ['related_to', 'depends_on', 'part_of', 'executed_by'];
|
const RELATION_TYPES = ['related_to', 'depends_on', 'part_of', 'executed_by'];
|
||||||
@@ -14,11 +14,12 @@ const RELATION_TYPES = ['related_to', 'depends_on', 'part_of', 'executed_by'];
|
|||||||
* Props:
|
* Props:
|
||||||
* selectedNode — the currently selected topic object, or null
|
* selectedNode — the currently selected topic object, or null
|
||||||
* topics — full topic list (used for the relation-target selector)
|
* topics — full topic list (used for the relation-target selector)
|
||||||
* relations — full relation list (to show outgoing edges)
|
* relations — full relation list (to show outgoing + incoming edges)
|
||||||
* onSave(topic) — called with the fully-merged updated topic
|
* onSave(topic) — called with the fully-merged updated topic
|
||||||
* onDelete() — called when the admin confirms deletion
|
* onDelete() — called when the admin confirms deletion
|
||||||
* onAddRelation(rel) — called with { source, target, type }
|
* onAddRelation(rel) — called with { source, target, type }
|
||||||
* onRemoveRelation(s,t,type) — called to drop a specific edge
|
* onRemoveRelation(s,t,type) — called to drop a specific edge
|
||||||
|
* onJumpToNode(id) — pan canvas to node and select it
|
||||||
*/
|
*/
|
||||||
export default function NodeDetailPanel({
|
export default function NodeDetailPanel({
|
||||||
selectedNode,
|
selectedNode,
|
||||||
@@ -28,6 +29,7 @@ export default function NodeDetailPanel({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onAddRelation,
|
onAddRelation,
|
||||||
onRemoveRelation,
|
onRemoveRelation,
|
||||||
|
onJumpToNode,
|
||||||
}) {
|
}) {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editData, setEditData] = useState({});
|
const [editData, setEditData] = useState({});
|
||||||
@@ -67,6 +69,7 @@ export default function NodeDetailPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const outgoing = relations.filter(r => r.source === selectedNode?.id);
|
const outgoing = relations.filter(r => r.source === selectedNode?.id);
|
||||||
|
const incoming = relations.filter(r => r.target === selectedNode?.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -218,8 +221,10 @@ export default function NodeDetailPanel({
|
|||||||
|
|
||||||
{/* Relations ───────────────────────────────────────────────────── */}
|
{/* Relations ───────────────────────────────────────────────────── */}
|
||||||
<div className="pt-4 border-t border-bg-warm">
|
<div className="pt-4 border-t border-bg-warm">
|
||||||
<p className="text-xs text-fg-muted uppercase tracking-wider mb-3">Relations</p>
|
{/* Outgoing relations */}
|
||||||
|
<p className="text-xs text-fg-muted uppercase tracking-wider mb-3">
|
||||||
|
Outgoing ({outgoing.length})
|
||||||
|
</p>
|
||||||
<div className="space-y-2 mb-4">
|
<div className="space-y-2 mb-4">
|
||||||
{outgoing.length === 0 && (
|
{outgoing.length === 0 && (
|
||||||
<p className="text-xs text-fg-muted italic">No outgoing relations.</p>
|
<p className="text-xs text-fg-muted italic">No outgoing relations.</p>
|
||||||
@@ -231,18 +236,63 @@ export default function NodeDetailPanel({
|
|||||||
key={idx}
|
key={idx}
|
||||||
className="flex items-center justify-between bg-bg rounded-[var(--r-sm)] p-2 border border-bg-warm"
|
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">
|
<div className="text-xs min-w-0 mr-2 flex-1">
|
||||||
<span className="font-mono text-teal">{rel.type}</span>
|
<span className="font-mono text-teal">{rel.type}</span>
|
||||||
{' → '}
|
{' → '}
|
||||||
<span className="truncate">{target ? target.label : rel.target}</span>
|
<span className="truncate">{target ? target.label : rel.target}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-0.5 shrink-0">
|
||||||
onClick={() => onRemoveRelation(selectedNode.id, rel.target, rel.type)}
|
{target && (
|
||||||
className="shrink-0 text-fg-muted hover:text-red-500 p-1"
|
<button
|
||||||
title="Remove relation"
|
onClick={() => onJumpToNode?.(rel.target)}
|
||||||
>
|
className="text-fg-muted hover:text-teal p-1"
|
||||||
<X size={14} />
|
title={`Jump to "${target.label}"`}
|
||||||
</button>
|
>
|
||||||
|
<ArrowUpRight size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onRemoveRelation(selectedNode.id, rel.target, rel.type)}
|
||||||
|
className="text-fg-muted hover:text-red-500 p-1"
|
||||||
|
title="Remove relation"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Incoming relations */}
|
||||||
|
<p className="text-xs text-fg-muted uppercase tracking-wider mb-3">
|
||||||
|
Incoming ({incoming.length})
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
{incoming.length === 0 && (
|
||||||
|
<p className="text-xs text-fg-muted italic">No incoming relations.</p>
|
||||||
|
)}
|
||||||
|
{incoming.map((rel, idx) => {
|
||||||
|
const source = topics.find(t => t.id === rel.source);
|
||||||
|
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 flex-1">
|
||||||
|
<span className="truncate">{source ? source.label : rel.source}</span>
|
||||||
|
{' → '}
|
||||||
|
<span className="font-mono text-teal">{rel.type}</span>
|
||||||
|
</div>
|
||||||
|
{source && (
|
||||||
|
<button
|
||||||
|
onClick={() => onJumpToNode?.(rel.source)}
|
||||||
|
className="shrink-0 text-fg-muted hover:text-teal p-1"
|
||||||
|
title={`Jump to "${source.label}"`}
|
||||||
|
>
|
||||||
|
<ArrowUpRight size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user