Topics met learning_relevance="exclude" of relevance_locked=true werden door de Full Analysis stilletjes verwijderd: het model zag exclude als "irrelevant" en stelde ze voor in actions.deletions / actions.merges, en analyzeGraph paste die zonder filter toe. Het locked-vlaggetje werd in full-scope payload niet eens meegestuurd, dus zelfs een nieuwe prompt kon niet helpen. Defense-in-depth fixes: 1. Pure graphGuard.filterAiActions strips elke deletion/merge waarvan de target excluded of locked is, vóór bulkSave. Merges waarin een protected topic juist de keepId is (canonical survivor) blijven door. 2. SYSTEM_PROMPTS.full krijgt een expliciete "PROTECTED TOPICS" sectie die exclude en locked topics als nooit-te-verwijderen markeert. 3. relevance_locked wordt nu ook in de full-scope compactTopics payload meegestuurd, zodat het model de vlag überhaupt ziet. 4. UI-feedback: GraphControls toont een ShieldCheck banner met aantal geblokkeerde acties na de analyze, en het excluded-aantal naast de "Show Excluded Nodes" toggle (visible/hidden). 5. 13 nieuwe Vitest cases dekken protected detection en alle drop/keep paden van filterAiActions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
524 lines
22 KiB
JavaScript
524 lines
22 KiB
JavaScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import * as d3 from 'd3';
|
|
import * as db from '../../lib/db';
|
|
import { callLLM } from '../../lib/llm';
|
|
import { EMIT_GRAPH_ACTIONS_TOOL } from '../../lib/llmTools';
|
|
import { Network, Table2 } from 'lucide-react';
|
|
import { useGraphData } from '../../hooks/useGraphData';
|
|
import { filterAiActions } from '../../lib/graphGuard';
|
|
import GraphControls from './graph/GraphControls';
|
|
import NodeDetailPanel from './graph/NodeDetailPanel';
|
|
import GraphTable from './graph/GraphTable';
|
|
|
|
// ── 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.
|
|
|
|
PROTECTED TOPICS — NEVER include these in merges (as deleteId) or in deletions:
|
|
• Topics with learning_relevance="exclude" are REFERENCE MATERIAL, kept on purpose
|
|
so R42 can still answer questions about them. They are intentionally outside
|
|
the theme-learning flow. Do not propose deleting or merging them away.
|
|
• Topics with relevance_locked=true are admin-pinned. Do not change their
|
|
learning_relevance and do not delete or merge them.
|
|
These topics may appear as the keepId in a merge (others fold into them), and
|
|
may appear as source/target of newRelations, but their own row must survive.
|
|
|
|
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).
|
|
Skip topics where relevance_locked=true — omit them from relevanceUpdates entirely.
|
|
|
|
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.
|
|
*
|
|
* Orchestrator: owns the D3 canvas, selected-node cursor, and AI analysis flow.
|
|
* Data lives in useGraphData; panel UI lives in GraphControls / NodeDetailPanel.
|
|
*/
|
|
const KnowledgeGraph = () => {
|
|
const svgRef = 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 [selectedNode, setSelectedNode] = useState(null);
|
|
const [showExcludeNodes, setShowExcludeNodes] = useState(false);
|
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
|
const [analyzeError, setAnalyzeError] = useState(null);
|
|
const [analyzeNotice, setAnalyzeNotice] = useState(null); // info-level message from last analyze
|
|
const [isRestoring, setIsRestoring] = useState(false);
|
|
const [viewMode, setViewMode] = useState('graph'); // 'graph' | 'table'
|
|
|
|
const {
|
|
topics, relations, snapshotMeta,
|
|
reload, updateTopic, bulkUpdateTopics, deleteTopic, addRelation, removeRelation, bulkSave, restoreSnapshot,
|
|
} = useGraphData();
|
|
|
|
// ── Canvas sizing ────────────────────────────────────────────────────────────
|
|
|
|
useEffect(() => {
|
|
if (!wrapperRef.current) return;
|
|
const measure = () => {
|
|
const { width, height } = wrapperRef.current.getBoundingClientRect();
|
|
setDimensions({ width, height });
|
|
};
|
|
measure();
|
|
window.addEventListener('resize', measure);
|
|
return () => window.removeEventListener('resize', measure);
|
|
}, []);
|
|
|
|
// ── D3 force graph ───────────────────────────────────────────────────────────
|
|
|
|
useEffect(() => {
|
|
if (viewMode !== 'graph') return;
|
|
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 filteredIds = new Set(filteredTopics.map(t => t.id));
|
|
const filteredRelations = relations.filter(
|
|
r => filteredIds.has(r.source) && filteredIds.has(r.target),
|
|
);
|
|
|
|
// Spread every datum so D3 simulation mutations don't touch React state.
|
|
const nodes = filteredTopics.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
|
|
.select(svgRef.current)
|
|
.attr('viewBox', [0, 0, width, height])
|
|
.attr('width', width)
|
|
.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 zoom = d3.zoom()
|
|
.scaleExtent([0.1, 4])
|
|
.on('zoom', event => g.attr('transform', event.transform));
|
|
|
|
svg.call(zoom);
|
|
zoomRef.current = zoom; // expose to panToNode
|
|
|
|
const color = d3
|
|
.scaleOrdinal()
|
|
.domain(['concept', 'role', 'process'])
|
|
.range(['#1F5560', '#5C1DD8', '#AFC0FF']);
|
|
|
|
const simulation = d3
|
|
.forceSimulation(nodes)
|
|
.force('link', d3.forceLink(links).id(d => d.id).distance(100))
|
|
.force('charge', d3.forceManyBody().strength(-300))
|
|
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
.force('collide', d3.forceCollide().radius(40));
|
|
|
|
// ── Edges — coloured and typed ─────────────────────────────────────────────
|
|
const link = g
|
|
.append('g')
|
|
.selectAll('line')
|
|
.data(links)
|
|
.join('line')
|
|
.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
|
|
.append('g')
|
|
.selectAll('g')
|
|
.data(nodes)
|
|
.join('g')
|
|
.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')
|
|
.attr('r', 20)
|
|
.attr('fill', d => color(d.type) || '#1F5560')
|
|
.attr('stroke', '#fff')
|
|
.attr('stroke-width', 2)
|
|
.attr('stroke-dasharray', d => (d.learning_relevance === 'exclude' ? '4,4' : 'none'))
|
|
.attr('opacity', d => {
|
|
if (d.learning_relevance === 'exclude') return 0.3;
|
|
if (d.learning_relevance === 'peripheral') return 0.5;
|
|
return 1;
|
|
})
|
|
.on('click', (_event, d) => setSelectedNode(d));
|
|
|
|
node
|
|
.append('text')
|
|
.text(d => d.label)
|
|
.attr('x', 25)
|
|
.attr('y', 5)
|
|
.attr('font-size', '12px')
|
|
.attr('fill', 'var(--color-fg)')
|
|
.attr('class', 'font-mono select-none pointer-events-none');
|
|
|
|
// ── Tick — offset line endpoints to circle edge so arrows land cleanly ─────
|
|
simulation.on('tick', () => {
|
|
link
|
|
.attr('x1', d => d.source.x)
|
|
.attr('y1', d => d.source.y)
|
|
.attr('x2', 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.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})`);
|
|
});
|
|
|
|
return () => simulation.stop();
|
|
}, [dimensions, topics, relations, showExcludeNodes, viewMode]);
|
|
|
|
// ── 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 ───────────────────────────────────────────────────
|
|
|
|
const handleNodeSave = useCallback(async (updatedTopic) => {
|
|
await updateTopic(updatedTopic);
|
|
setSelectedNode(updatedTopic);
|
|
}, [updateTopic]);
|
|
|
|
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);
|
|
}
|
|
}, [selectedNode, deleteTopic]);
|
|
|
|
// ── Snapshot restore ─────────────────────────────────────────────────────────
|
|
|
|
const handleRestore = useCallback(async () => {
|
|
if (!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.`,
|
|
)) return;
|
|
setIsRestoring(true);
|
|
try {
|
|
await restoreSnapshot();
|
|
setSelectedNode(null);
|
|
} finally {
|
|
setIsRestoring(false);
|
|
}
|
|
}, [restoreSnapshot, snapshotMeta]);
|
|
|
|
// ── AI graph analysis (scoped) ───────────────────────────────────────────────
|
|
|
|
const analyzeGraph = useCallback(async (scope = 'full') => {
|
|
if (!confirm(SCOPE_CONFIRM[scope])) return;
|
|
|
|
setIsAnalyzing(true);
|
|
setAnalyzeError(null);
|
|
setAnalyzeNotice(null);
|
|
|
|
try {
|
|
const [currentTopics, currentRelations] = await Promise.all([
|
|
db.getTopics(),
|
|
db.getRelations(),
|
|
]);
|
|
|
|
const tier = scope === 'full' ? 'reasoning' : 'standard';
|
|
|
|
// For full + relevance scopes the model needs to see relevance_locked so
|
|
// it can honor the "do not touch protected topics" rule. (relations-scope
|
|
// only emits newRelations, so the flag is irrelevant there.)
|
|
const sendLocked = scope === 'full' || scope === 'relevance';
|
|
const compactTopics = currentTopics.map(t => ({
|
|
id: t.id,
|
|
label: t.label,
|
|
type: t.type,
|
|
learning_relevance: t.learning_relevance,
|
|
...(sendLocked && t.relevance_locked ? { relevance_locked: true } : {}),
|
|
}));
|
|
const compactRelations = currentRelations.map(({ source, target, type }) => ({
|
|
source, target, type,
|
|
}));
|
|
|
|
const llmResult = await callLLM({
|
|
task: `graph.analyze.${scope}`,
|
|
tier,
|
|
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 })}`,
|
|
tools: [EMIT_GRAPH_ACTIONS_TOOL],
|
|
toolChoice: { type: 'tool', name: EMIT_GRAPH_ACTIONS_TOOL.name },
|
|
maxTokens: scope === 'full' ? 4096 : 2048,
|
|
});
|
|
|
|
const rawActions = llmResult.toolUses[0]?.input;
|
|
if (!rawActions) throw new Error('Graph analysis did not emit a tool result.');
|
|
|
|
// ── Safety guard ─────────────────────────────────────────────────────
|
|
// Strip merges/deletions that target excluded or locked topics. Excluded
|
|
// topics are reference material kept on purpose for R42; locked topics
|
|
// are admin-pinned. The AI may suggest removing them anyway — we drop
|
|
// those suggestions before they reach bulkSave.
|
|
const { filtered: actions, dropped } = filterAiActions(currentTopics, rawActions);
|
|
const droppedCount = dropped.deletions.length + dropped.merges.length;
|
|
if (droppedCount > 0) {
|
|
// Visible feedback so the admin knows the AI tried to touch protected rows.
|
|
console.warn(
|
|
`[graph guard] Blocked ${droppedCount} destructive action(s) on protected topics`,
|
|
dropped,
|
|
);
|
|
setAnalyzeNotice(
|
|
`Guard blocked ${droppedCount} destructive AI action${droppedCount === 1 ? '' : 's'} on excluded or locked topics. ${dropped.deletions.length} deletion${dropped.deletions.length === 1 ? '' : 's'}, ${dropped.merges.length} merge${dropped.merges.length === 1 ? '' : 's'}.`,
|
|
);
|
|
}
|
|
|
|
let updatedTopics = [...currentTopics];
|
|
let updatedRelations = [...currentRelations];
|
|
|
|
// Full scope: merges + deletions
|
|
if (scope === 'full') {
|
|
for (const merge of actions.merges ?? []) {
|
|
updatedTopics = updatedTopics.filter(t => t.id !== merge.deleteId);
|
|
updatedRelations = updatedRelations.map(r => ({
|
|
...r,
|
|
source: r.source === merge.deleteId ? merge.keepId : r.source,
|
|
target: r.target === merge.deleteId ? merge.keepId : 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),
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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));
|
|
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">
|
|
{/* Main view: graph canvas or table */}
|
|
<div className="flex-1 flex flex-col border-r border-bg-warm min-w-0">
|
|
{/* View-mode toggle */}
|
|
<div className="flex items-center gap-1 px-3 py-2 border-b border-bg-warm bg-paper">
|
|
<button
|
|
onClick={() => setViewMode('graph')}
|
|
className={`flex items-center gap-1.5 px-3 py-1 text-xs rounded-[var(--r-sm)] transition-colors ${
|
|
viewMode === 'graph' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50'
|
|
}`}
|
|
title="Force-directed graph view"
|
|
>
|
|
<Network size={13} /> Graph
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode('table')}
|
|
className={`flex items-center gap-1.5 px-3 py-1 text-xs rounded-[var(--r-sm)] transition-colors ${
|
|
viewMode === 'table' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50'
|
|
}`}
|
|
title="Sortable table with bulk edit"
|
|
>
|
|
<Table2 size={13} /> Table
|
|
</button>
|
|
</div>
|
|
|
|
{viewMode === 'graph' ? (
|
|
<div
|
|
ref={wrapperRef}
|
|
className="flex-1 h-[400px] md:h-full cursor-grab active:cursor-grabbing"
|
|
>
|
|
{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.
|
|
</div>
|
|
) : (
|
|
<svg ref={svgRef} className="w-full h-full" />
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 min-h-[400px] overflow-hidden">
|
|
{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.
|
|
</div>
|
|
) : (
|
|
<GraphTable
|
|
topics={topics}
|
|
relations={relations}
|
|
onBulkUpdate={bulkUpdateTopics}
|
|
onSelectNode={setSelectedNode}
|
|
/>
|
|
)}
|
|
</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">
|
|
<GraphControls
|
|
showExcludeNodes={showExcludeNodes}
|
|
onShowExcludeChange={setShowExcludeNodes}
|
|
excludedCount={topics.filter(t => t.learning_relevance === 'exclude').length}
|
|
onAnalyze={analyzeGraph}
|
|
isAnalyzing={isAnalyzing}
|
|
analyzeError={analyzeError}
|
|
analyzeNotice={analyzeNotice}
|
|
disabled={topics.length === 0}
|
|
onApplied={reload}
|
|
snapshotMeta={snapshotMeta}
|
|
onRestore={handleRestore}
|
|
isRestoring={isRestoring}
|
|
/>
|
|
<NodeDetailPanel
|
|
selectedNode={selectedNode}
|
|
topics={topics}
|
|
relations={relations}
|
|
onSave={handleNodeSave}
|
|
onDelete={handleNodeDelete}
|
|
onAddRelation={addRelation}
|
|
onRemoveRelation={removeRelation}
|
|
onJumpToNode={handleJumpToNode}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default KnowledgeGraph;
|