Voegt een Graph/Table toggle toe aan de admin Knowledge Graph. De tabel ondersteunt sorteren per kolom, groeperen op type/relevance/theme/difficulty, filteren op label, multi-select en bulk-wijzigen van type, learning_relevance en relevance_locked. Closes #10 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
193 lines
7.2 KiB
JavaScript
193 lines
7.2 KiB
JavaScript
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.
|
|
*
|
|
* Snapshot contract:
|
|
* bulkSave() writes a snapshot of the CURRENT state to PocketBase before
|
|
* overwriting it — so the admin can always roll back one step. snapshotMeta
|
|
* is non-null whenever a rollback point exists; restoreSnapshot() applies it
|
|
* and then clears it so it cannot be applied twice.
|
|
*/
|
|
export function useGraphData() {
|
|
const [topics, setTopics] = useState([]);
|
|
const [relations, setRelations] = useState([]);
|
|
|
|
// { ts: number, topicCount: number, relationCount: number }
|
|
const [snapshotMeta, setSnapshotMeta] = useState(null);
|
|
|
|
// 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]);
|
|
|
|
// On mount, check whether a snapshot from a previous session exists.
|
|
useEffect(() => {
|
|
const snap = db.getGraphSnapshot();
|
|
if (snap?.ts) {
|
|
setSnapshotMeta({ ts: snap.ts, topicCount: snap.topicCount, relationCount: snap.relationCount });
|
|
}
|
|
}, []);
|
|
|
|
// ── Single-topic mutations ───────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Persist a topic update and mirror it into local state.
|
|
* 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 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;
|
|
}, []);
|
|
|
|
/**
|
|
* Apply the same patch to many topics in one call. `ids` is the set of
|
|
* topic IDs to update; `patch` is the partial-topic to merge into each one
|
|
* (e.g. `{ type: 'process' }` or `{ learning_relevance: 'core', relevance_locked: true }`).
|
|
*
|
|
* Persists each topic via db.upsertTopic in parallel and mirrors into state.
|
|
* Returns the number of topics actually written (selected ∩ existing).
|
|
*/
|
|
const bulkUpdateTopics = useCallback(async (ids, patch) => {
|
|
const idSet = new Set(ids);
|
|
const targets = topicsRef.current.filter(t => idSet.has(t.id));
|
|
const updated = targets.map(t => ({ ...t, ...patch }));
|
|
await Promise.all(updated.map(t => db.upsertTopic(t)));
|
|
setTopics(prev => prev.map(t => (idSet.has(t.id) ? { ...t, ...patch } : t)));
|
|
return updated.length;
|
|
}, []);
|
|
|
|
/** 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) ────────────────────────────────────
|
|
|
|
/**
|
|
* Snapshot the current graph then replace everything in PocketBase and local
|
|
* state. The snapshot is written BEFORE the destructive delete-all/create-all
|
|
* so it survives a mid-operation failure.
|
|
*
|
|
* Called by analyzeGraph after all AI actions have been applied in memory.
|
|
*/
|
|
const bulkSave = useCallback(async (newTopics, newRelations) => {
|
|
// Persist a rollback point of the state we are about to overwrite.
|
|
db.saveGraphSnapshot(topicsRef.current, relationsRef.current);
|
|
setSnapshotMeta({
|
|
ts: Date.now(),
|
|
topicCount: topicsRef.current.length,
|
|
relationCount: relationsRef.current.length,
|
|
});
|
|
|
|
await db.saveTopics(newTopics);
|
|
await db.saveRelations(newRelations);
|
|
setTopics(newTopics);
|
|
setRelations(newRelations);
|
|
}, []);
|
|
|
|
/**
|
|
* Load the latest snapshot from PocketBase and apply it as the current graph.
|
|
* Clears the snapshot afterwards so the admin cannot restore the same point
|
|
* twice (which would silently re-apply the AI changes they just rolled back).
|
|
*
|
|
* Returns true on success, false if no snapshot exists.
|
|
*/
|
|
const restoreSnapshot = useCallback(async () => {
|
|
const snap = db.getGraphSnapshot();
|
|
if (!snap?.topics) return false;
|
|
|
|
// Apply without taking a new snapshot — we don't want to overwrite
|
|
// the only rollback point we have.
|
|
await db.saveTopics(snap.topics);
|
|
await db.saveRelations(snap.relations);
|
|
setTopics(snap.topics);
|
|
setRelations(snap.relations);
|
|
|
|
db.clearGraphSnapshot();
|
|
setSnapshotMeta(null);
|
|
return true;
|
|
}, []);
|
|
|
|
return {
|
|
topics,
|
|
relations,
|
|
snapshotMeta,
|
|
reload,
|
|
updateTopic,
|
|
bulkUpdateTopics,
|
|
deleteTopic,
|
|
addRelation,
|
|
removeRelation,
|
|
bulkSave,
|
|
restoreSnapshot,
|
|
};
|
|
}
|