feat: implement interactive knowledge graph visualization and AI-driven graph optimization dashboard
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import * as d3 from 'd3';
|
||||
import { Trash2, Edit2, Save, X } from 'lucide-react';
|
||||
import { Trash2, Edit2, Save, X, RefreshCw, AlertCircle, Plus, Link as LinkIcon } from 'lucide-react';
|
||||
import { storage } from '../../lib/storage';
|
||||
import { anthropicApi } from '../../lib/api';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
const KnowledgeGraph = () => {
|
||||
@@ -11,6 +12,9 @@ const KnowledgeGraph = () => {
|
||||
const [selectedNode, setSelectedNode] = useState(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editData, setEditData] = useState({});
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [analyzeError, setAnalyzeError] = useState(null);
|
||||
const [newRelData, setNewRelData] = useState({ target: '', type: 'related_to' });
|
||||
|
||||
// Load data into state so it can update
|
||||
const [topics, setTopics] = useState([]);
|
||||
@@ -61,8 +65,8 @@ const KnowledgeGraph = () => {
|
||||
svg.call(zoom);
|
||||
|
||||
const color = d3.scaleOrdinal()
|
||||
.domain(['concept', 'role', 'process', 'fact'])
|
||||
.range(['#1F5560', '#5C1DD8', '#AFC0FF', '#E2E8F0']);
|
||||
.domain(['concept', 'role', 'process'])
|
||||
.range(['#1F5560', '#5C1DD8', '#AFC0FF']);
|
||||
|
||||
const simulation = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink(links).id(d => d.id).distance(100))
|
||||
@@ -166,6 +170,110 @@ const KnowledgeGraph = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const addRelation = () => {
|
||||
if (!newRelData.target) return;
|
||||
const rawRelations = storage.get('kb:relations', []);
|
||||
const isDup = rawRelations.some(r => r.source === selectedNode.id && r.target === newRelData.target && r.type === newRelData.type);
|
||||
if (isDup) return;
|
||||
|
||||
const updated = [...rawRelations, { source: selectedNode.id, target: newRelData.target, type: newRelData.type }];
|
||||
storage.set('kb:relations', updated);
|
||||
setRelations(updated);
|
||||
setNewRelData({ target: '', type: 'related_to' });
|
||||
};
|
||||
|
||||
const removeRelation = (sourceId, targetId, type) => {
|
||||
const rawRelations = storage.get('kb:relations', []);
|
||||
const updated = rawRelations.filter(r => !(r.source === sourceId && r.target === targetId && r.type === type));
|
||||
storage.set('kb:relations', updated);
|
||||
setRelations(updated);
|
||||
};
|
||||
|
||||
const analyzeGraph = async () => {
|
||||
if (!confirm('This will send the entire graph to the AI to evaluate content, merge duplicates, and create new logical relations. This may take a moment. Proceed?')) return;
|
||||
|
||||
setIsAnalyzing(true);
|
||||
setAnalyzeError(null);
|
||||
|
||||
try {
|
||||
const currentTopics = storage.get('kb:topics', []);
|
||||
const currentRelations = storage.get('kb:relations', []);
|
||||
|
||||
const systemPrompt = `You are a strict Data Quality AI maintaining a Knowledge Graph.
|
||||
Your goal is to evaluate the provided topics and relations, identify duplicates to merge, useless nodes to delete, and new logical relations to add.
|
||||
|
||||
Rules:
|
||||
1. Identify topics that mean exactly the same thing. Choose one to keep, and one to delete.
|
||||
2. Identify topics that are too vague, irrelevant, or malformed to delete.
|
||||
3. Identify missing logical relations (depends_on, part_of, related_to) if two topics are conceptually linked but missing a relation.
|
||||
4. Return ONLY a valid JSON object describing the ACTIONS to take. Do not return the entire graph. Do not wrap in markdown blocks.`;
|
||||
|
||||
const userPrompt = `Here is the current knowledge graph:
|
||||
${JSON.stringify({ topics: currentTopics, relations: currentRelations }, null, 2)}
|
||||
|
||||
Analyze this graph and return ONLY the optimized JSON object with this EXACT structure:
|
||||
{
|
||||
"merges": [ { "keepId": "id_to_keep", "deleteId": "id_to_delete" } ],
|
||||
"deletions": [ "id_to_delete_completely" ],
|
||||
"newRelations": [ { "source": "id1", "target": "id2", "type": "depends_on" } ]
|
||||
}`;
|
||||
|
||||
const responseText = await anthropicApi.generateContent(systemPrompt, userPrompt);
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) throw new Error('AI returned invalid format.');
|
||||
|
||||
const actions = JSON.parse(jsonMatch[0]);
|
||||
|
||||
let updatedTopics = [...currentTopics];
|
||||
let updatedRelations = [...currentRelations];
|
||||
|
||||
// Process Merges
|
||||
if (actions.merges && Array.isArray(actions.merges)) {
|
||||
for (const merge of actions.merges) {
|
||||
updatedTopics = updatedTopics.filter(t => t.id !== merge.deleteId);
|
||||
// Redirect relations
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process Deletions
|
||||
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));
|
||||
}
|
||||
|
||||
// Process New Relations
|
||||
if (actions.newRelations && Array.isArray(actions.newRelations)) {
|
||||
for (const newRel of actions.newRelations) {
|
||||
// Prevent exact duplicates
|
||||
const isDup = updatedRelations.some(r => r.source === newRel.source && r.target === newRel.target && r.type === newRel.type);
|
||||
if (!isDup) {
|
||||
updatedRelations.push(newRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final cleanup to remove self-referencing relations that might occur after merges
|
||||
updatedRelations = updatedRelations.filter(r => r.source !== r.target);
|
||||
|
||||
storage.set('kb:topics', updatedTopics);
|
||||
storage.set('kb:relations', updatedRelations);
|
||||
|
||||
setTopics(updatedTopics);
|
||||
setRelations(updatedRelations);
|
||||
setSelectedNode(null);
|
||||
|
||||
} catch (e) {
|
||||
setAnalyzeError(e.message || 'Analysis failed. Please try again.');
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
@@ -180,6 +288,24 @@ const KnowledgeGraph = () => {
|
||||
|
||||
{/* Node Info Panel */}
|
||||
<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">
|
||||
{/* Global Action */}
|
||||
<div className="mb-6 pb-4 border-b border-bg-warm">
|
||||
<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 className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-bold text-lg text-teal">Node Details</h3>
|
||||
{selectedNode && !isEditing && (
|
||||
@@ -215,7 +341,6 @@ const KnowledgeGraph = () => {
|
||||
<option value="concept">Concept</option>
|
||||
<option value="role">Role</option>
|
||||
<option value="process">Process</option>
|
||||
<option value="fact">Fact</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -251,6 +376,64 @@ const KnowledgeGraph = () => {
|
||||
<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>
|
||||
|
||||
{/* Existing Relations where selectedNode is source */}
|
||||
<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>
|
||||
|
||||
{/* Add New 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})}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user