feat: implement snapshot restore functionality and enhance graph state management
This commit is contained in:
@@ -25,9 +25,12 @@ const KnowledgeGraph = () => {
|
|||||||
const [showExcludeNodes, setShowExcludeNodes] = useState(false);
|
const [showExcludeNodes, setShowExcludeNodes] = useState(false);
|
||||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
const [analyzeError, setAnalyzeError] = useState(null);
|
const [analyzeError, setAnalyzeError] = useState(null);
|
||||||
|
const [isRestoring, setIsRestoring] = useState(false);
|
||||||
|
|
||||||
const { topics, relations, reload, updateTopic, deleteTopic, addRelation, removeRelation, bulkSave } =
|
const {
|
||||||
useGraphData();
|
topics, relations, snapshotMeta,
|
||||||
|
reload, updateTopic, deleteTopic, addRelation, removeRelation, bulkSave, restoreSnapshot,
|
||||||
|
} = useGraphData();
|
||||||
|
|
||||||
// ── Canvas sizing ────────────────────────────────────────────────────────────
|
// ── Canvas sizing ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -177,6 +180,24 @@ const KnowledgeGraph = () => {
|
|||||||
}
|
}
|
||||||
}, [selectedNode, deleteTopic]);
|
}, [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 & Optimize run. The snapshot will be cleared after restoring.`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
setIsRestoring(true);
|
||||||
|
try {
|
||||||
|
await restoreSnapshot();
|
||||||
|
setSelectedNode(null);
|
||||||
|
} finally {
|
||||||
|
setIsRestoring(false);
|
||||||
|
}
|
||||||
|
}, [restoreSnapshot, snapshotMeta]);
|
||||||
|
|
||||||
// ── AI graph analysis ────────────────────────────────────────────────────────
|
// ── AI graph analysis ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const analyzeGraph = useCallback(async () => {
|
const analyzeGraph = useCallback(async () => {
|
||||||
@@ -309,6 +330,9 @@ Do not return the entire graph — only the actions to take.`;
|
|||||||
analyzeError={analyzeError}
|
analyzeError={analyzeError}
|
||||||
disabled={topics.length === 0}
|
disabled={topics.length === 0}
|
||||||
onApplied={reload}
|
onApplied={reload}
|
||||||
|
snapshotMeta={snapshotMeta}
|
||||||
|
onRestore={handleRestore}
|
||||||
|
isRestoring={isRestoring}
|
||||||
/>
|
/>
|
||||||
<NodeDetailPanel
|
<NodeDetailPanel
|
||||||
selectedNode={selectedNode}
|
selectedNode={selectedNode}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { RefreshCw, AlertCircle } from 'lucide-react';
|
import { RotateCcw, RefreshCw, AlertCircle } from 'lucide-react';
|
||||||
import Button from '../../ui/Button';
|
import Button from '../../ui/Button';
|
||||||
import SuggestionsQueue from '../SuggestionsQueue';
|
import SuggestionsQueue from '../SuggestionsQueue';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sidebar controls panel: the exclude-nodes toggle, the AI analysis button,
|
* Sidebar controls panel: the exclude-nodes toggle, the AI analysis button,
|
||||||
* and the R42 suggestions queue.
|
* 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.
|
||||||
@@ -17,7 +17,14 @@ export default function GraphControls({
|
|||||||
analyzeError,
|
analyzeError,
|
||||||
disabled,
|
disabled,
|
||||||
onApplied,
|
onApplied,
|
||||||
|
snapshotMeta,
|
||||||
|
onRestore,
|
||||||
|
isRestoring,
|
||||||
}) {
|
}) {
|
||||||
|
const snapshotLabel = snapshotMeta
|
||||||
|
? new Date(snapshotMeta.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 pb-4 border-b border-bg-warm space-y-3">
|
<div className="mb-6 pb-4 border-b border-bg-warm space-y-3">
|
||||||
<label className="flex items-center gap-2 text-sm text-fg-muted cursor-pointer">
|
<label className="flex items-center gap-2 text-sm text-fg-muted cursor-pointer">
|
||||||
@@ -30,18 +37,33 @@ export default function GraphControls({
|
|||||||
Show Excluded Nodes (Reference Material)
|
Show Excluded Nodes (Reference Material)
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={onAnalyze}
|
onClick={onAnalyze}
|
||||||
disabled={isAnalyzing || 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 Graph…' : 'Analyze & Optimize Graph'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Restore row — only visible after a bulkSave has written a snapshot */}
|
||||||
|
{snapshotMeta && (
|
||||||
|
<button
|
||||||
|
onClick={onRestore}
|
||||||
|
disabled={isAnalyzing || isRestoring}
|
||||||
|
className="w-full flex items-center justify-center gap-1.5 text-xs text-fg-muted hover:text-teal disabled:opacity-40 disabled:cursor-not-allowed py-1 transition-colors"
|
||||||
|
title={`Restore graph to state from ${snapshotLabel} (${snapshotMeta.topicCount} topics, ${snapshotMeta.relationCount} relations)`}
|
||||||
|
>
|
||||||
|
<RotateCcw size={12} className={isRestoring ? 'animate-spin' : ''} />
|
||||||
|
{isRestoring
|
||||||
|
? 'Restoring…'
|
||||||
|
: `Restore to ${snapshotLabel} · ${snapshotMeta.topicCount}t ${snapshotMeta.relationCount}r`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{analyzeError && (
|
{analyzeError && (
|
||||||
<p className="mt-2 text-xs text-red-600 flex items-start gap-1">
|
<p className="text-xs text-red-600 flex items-start gap-1">
|
||||||
<AlertCircle size={14} className="shrink-0 mt-0.5" />
|
<AlertCircle size={14} className="shrink-0 mt-0.5" />
|
||||||
{analyzeError}
|
{analyzeError}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -10,11 +10,20 @@ import * as db from '../lib/db';
|
|||||||
* Relation shape contract: source and target are always plain strings.
|
* Relation shape contract: source and target are always plain strings.
|
||||||
* The normalization in db.getRelations() enforces this at the DB boundary;
|
* The normalization in db.getRelations() enforces this at the DB boundary;
|
||||||
* the filter here drops any orphaned edges whose endpoints no longer exist.
|
* 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() {
|
export function useGraphData() {
|
||||||
const [topics, setTopics] = useState([]);
|
const [topics, setTopics] = useState([]);
|
||||||
const [relations, setRelations] = 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
|
// Refs that always hold the latest state — lets mutation callbacks avoid
|
||||||
// stale-closure bugs without including state in their dependency arrays.
|
// stale-closure bugs without including state in their dependency arrays.
|
||||||
const topicsRef = useRef([]);
|
const topicsRef = useRef([]);
|
||||||
@@ -41,11 +50,19 @@ export function useGraphData() {
|
|||||||
return () => window.removeEventListener('respellion:kb-updated', reload);
|
return () => window.removeEventListener('respellion:kb-updated', reload);
|
||||||
}, [reload]);
|
}, [reload]);
|
||||||
|
|
||||||
|
// On mount, check whether a snapshot from a previous session exists.
|
||||||
|
useEffect(() => {
|
||||||
|
db.getGraphSnapshot().then(snap => {
|
||||||
|
if (snap?.ts) {
|
||||||
|
setSnapshotMeta({ ts: snap.ts, topicCount: snap.topicCount, relationCount: snap.relationCount });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ── Single-topic mutations ───────────────────────────────────────────────────
|
// ── Single-topic mutations ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist a topic update and mirror it into local state.
|
* Persist a topic update and mirror it into local state.
|
||||||
* Automatically locks learning_relevance when the admin changes it.
|
|
||||||
* Returns the saved topic object.
|
* Returns the saved topic object.
|
||||||
*/
|
*/
|
||||||
const updateTopic = useCallback(async (topic) => {
|
const updateTopic = useCallback(async (topic) => {
|
||||||
@@ -74,7 +91,7 @@ export function useGraphData() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a relation if not already present.
|
* Add a relation if not already present.
|
||||||
* Returns true if the relation was added, false if it was a duplicate.
|
* Returns true if added, false if it was a duplicate.
|
||||||
*/
|
*/
|
||||||
const addRelation = useCallback(async (relation) => {
|
const addRelation = useCallback(async (relation) => {
|
||||||
const isDup = relationsRef.current.some(
|
const isDup = relationsRef.current.some(
|
||||||
@@ -99,24 +116,60 @@ export function useGraphData() {
|
|||||||
// ── Bulk mutations (used by AI analysis) ────────────────────────────────────
|
// ── Bulk mutations (used by AI analysis) ────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atomically replace the entire graph in PocketBase and local state.
|
* Snapshot the current graph then replace everything in PocketBase and local
|
||||||
* Called by analyzeGraph after all AI actions have been applied locally.
|
* 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) => {
|
const bulkSave = useCallback(async (newTopics, newRelations) => {
|
||||||
|
// Persist a rollback point of the state we are about to overwrite.
|
||||||
|
await db.saveGraphSnapshot(topicsRef.current, relationsRef.current);
|
||||||
|
setSnapshotMeta({
|
||||||
|
ts: Date.now(),
|
||||||
|
topicCount: topicsRef.current.length,
|
||||||
|
relationCount: relationsRef.current.length,
|
||||||
|
});
|
||||||
|
|
||||||
await db.saveTopics(newTopics);
|
await db.saveTopics(newTopics);
|
||||||
await db.saveRelations(newRelations);
|
await db.saveRelations(newRelations);
|
||||||
setTopics(newTopics);
|
setTopics(newTopics);
|
||||||
setRelations(newRelations);
|
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 = await 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);
|
||||||
|
|
||||||
|
await db.clearGraphSnapshot();
|
||||||
|
setSnapshotMeta(null);
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
topics,
|
topics,
|
||||||
relations,
|
relations,
|
||||||
|
snapshotMeta,
|
||||||
reload,
|
reload,
|
||||||
updateTopic,
|
updateTopic,
|
||||||
deleteTopic,
|
deleteTopic,
|
||||||
addRelation,
|
addRelation,
|
||||||
removeRelation,
|
removeRelation,
|
||||||
bulkSave,
|
bulkSave,
|
||||||
|
restoreSnapshot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,6 +284,60 @@ export function setSetting(key, value) {
|
|||||||
return pbUpsert('settings', `key="${key}"`, { value: String(value) }, { key, value: String(value) });
|
return pbUpsert('settings', `key="${key}"`, { value: String(value) }, { key, value: String(value) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Graph Snapshots ───────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A single "latest" snapshot is persisted in the settings collection before
|
||||||
|
// every bulk graph write. This survives browser/tab close and lets the admin
|
||||||
|
// roll back an AI analysis that produced unwanted results.
|
||||||
|
//
|
||||||
|
// The value is stored as a JSON string because the settings collection only
|
||||||
|
// has a plain text `value` field. Large graphs (150+ topics) can produce
|
||||||
|
// ~100 KB of JSON, which SQLite handles without issue.
|
||||||
|
|
||||||
|
const SNAPSHOT_KEY = 'graph_snapshot:latest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the current graph state as a rollback point.
|
||||||
|
* Called by useGraphData.bulkSave() before overwriting the graph.
|
||||||
|
*
|
||||||
|
* @param {object[]} topics — the graph state to preserve
|
||||||
|
* @param {object[]} relations — the graph state to preserve
|
||||||
|
*/
|
||||||
|
export async function saveGraphSnapshot(topics, relations) {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
ts: Date.now(),
|
||||||
|
topicCount: topics.length,
|
||||||
|
relationCount: relations.length,
|
||||||
|
topics,
|
||||||
|
relations,
|
||||||
|
});
|
||||||
|
return setSetting(SNAPSHOT_KEY, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the latest snapshot back from PocketBase.
|
||||||
|
* Returns null when no snapshot exists or the stored value is malformed.
|
||||||
|
*
|
||||||
|
* @returns {{ ts: number, topicCount: number, relationCount: number, topics: object[], relations: object[] } | null}
|
||||||
|
*/
|
||||||
|
export async function getGraphSnapshot() {
|
||||||
|
const raw = await getSetting(SNAPSHOT_KEY, null);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the stored snapshot (e.g. after a successful restore so the
|
||||||
|
* admin cannot accidentally restore twice).
|
||||||
|
*/
|
||||||
|
export function clearGraphSnapshot() {
|
||||||
|
return setSetting(SNAPSHOT_KEY, '');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Curriculum Versions (v2) ──────────────────────────────────────────────────
|
// ── Curriculum Versions (v2) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getCurriculumVersions(status) {
|
export async function getCurriculumVersions(status) {
|
||||||
|
|||||||
Reference in New Issue
Block a user