feat: add GraphControls component and useGraphData hook for knowledge graph management
This commit is contained in:
122
src/hooks/useGraphData.js
Normal file
122
src/hooks/useGraphData.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import * as db from '../lib/db';
|
||||
|
||||
/**
|
||||
* Centralises all knowledge-graph data fetching and mutation.
|
||||
*
|
||||
* Returns stable callback refs so callers can safely list them in
|
||||
* useEffect dependency arrays without causing infinite loops.
|
||||
*
|
||||
* Relation shape contract: source and target are always plain strings.
|
||||
* The normalization in db.getRelations() enforces this at the DB boundary;
|
||||
* the filter here drops any orphaned edges whose endpoints no longer exist.
|
||||
*/
|
||||
export function useGraphData() {
|
||||
const [topics, setTopics] = useState([]);
|
||||
const [relations, setRelations] = useState([]);
|
||||
|
||||
// Refs that always hold the latest state — lets mutation callbacks avoid
|
||||
// stale-closure bugs without including state in their dependency arrays.
|
||||
const topicsRef = useRef([]);
|
||||
const relationsRef = useRef([]);
|
||||
|
||||
useEffect(() => { topicsRef.current = topics; }, [topics]);
|
||||
useEffect(() => { relationsRef.current = relations; }, [relations]);
|
||||
|
||||
// ── Load ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const [allTopics, allRelations] = await Promise.all([
|
||||
db.getTopics(),
|
||||
db.getRelations(), // already normalised to string IDs
|
||||
]);
|
||||
const topicIds = new Set(allTopics.map(t => t.id));
|
||||
setTopics(allTopics);
|
||||
setRelations(allRelations.filter(r => topicIds.has(r.source) && topicIds.has(r.target)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
window.addEventListener('respellion:kb-updated', reload);
|
||||
return () => window.removeEventListener('respellion:kb-updated', reload);
|
||||
}, [reload]);
|
||||
|
||||
// ── Single-topic mutations ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Persist a topic update and mirror it into local state.
|
||||
* Automatically locks learning_relevance when the admin changes it.
|
||||
* Returns the saved topic object.
|
||||
*/
|
||||
const updateTopic = useCallback(async (topic) => {
|
||||
await db.upsertTopic(topic);
|
||||
setTopics(prev => prev.map(t => (t.id === topic.id ? topic : t)));
|
||||
return topic;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Delete a topic plus all relations that reference it.
|
||||
* Uses the ref so the callback is stable even as state changes.
|
||||
*/
|
||||
const deleteTopic = useCallback(async (topicId) => {
|
||||
const updatedTopics = topicsRef.current.filter(t => t.id !== topicId);
|
||||
const updatedRelations = relationsRef.current.filter(
|
||||
r => r.source !== topicId && r.target !== topicId,
|
||||
);
|
||||
await db.saveTopics(updatedTopics);
|
||||
await db.saveRelations(updatedRelations);
|
||||
await db.deleteContent(topicId);
|
||||
setTopics(updatedTopics);
|
||||
setRelations(updatedRelations);
|
||||
}, []);
|
||||
|
||||
// ── Relation mutations ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add a relation if not already present.
|
||||
* Returns true if the relation was added, false if it was a duplicate.
|
||||
*/
|
||||
const addRelation = useCallback(async (relation) => {
|
||||
const isDup = relationsRef.current.some(
|
||||
r => r.source === relation.source &&
|
||||
r.target === relation.target &&
|
||||
r.type === relation.type,
|
||||
);
|
||||
if (isDup) return false;
|
||||
await db.addRelation(relation);
|
||||
setRelations(prev => [...prev, relation]);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
/** Remove a specific (source, target, type) relation triple. */
|
||||
const removeRelation = useCallback(async (source, target, type) => {
|
||||
await db.removeRelation(source, target, type);
|
||||
setRelations(prev =>
|
||||
prev.filter(r => !(r.source === source && r.target === target && r.type === type)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// ── Bulk mutations (used by AI analysis) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Atomically replace the entire graph in PocketBase and local state.
|
||||
* Called by analyzeGraph after all AI actions have been applied locally.
|
||||
*/
|
||||
const bulkSave = useCallback(async (newTopics, newRelations) => {
|
||||
await db.saveTopics(newTopics);
|
||||
await db.saveRelations(newRelations);
|
||||
setTopics(newTopics);
|
||||
setRelations(newRelations);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
topics,
|
||||
relations,
|
||||
reload,
|
||||
updateTopic,
|
||||
deleteTopic,
|
||||
addRelation,
|
||||
removeRelation,
|
||||
bulkSave,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user