Files
fusion/packages/dashboard/app/hooks/useKnowledgeGraph.ts
gsxdsm dad726c777 FN-9038: add Knowledge Graph tab to Memory view
Expose knowledge graph data in the Memory view through a localized, resilient dashboard panel.

- Add protected knowledge graph API routes and access helpers.
- Add a Memory view tab, graph panel, loading/error states, and localized labels.
- Document graph navigation and cover access, route, hook, and component behavior.

Files changed:
 .changeset/fn-9038-knowledge-graph-tab.md          |   7 +
 docs/dashboard-guide.md                            |   2 +
 docs/knowledge-graph.md                            |  10 ++
 packages/dashboard/app/api/legacy.ts               |  17 ++-
 packages/dashboard/app/api/system/index.ts         |   1 +
 .../dashboard/app/api/system/knowledge-graph.ts    |  23 +++
 .../app/components/KnowledgeGraphPanel.css         |  35 +++++
 .../app/components/KnowledgeGraphPanel.tsx         |  68 +++++++++
 packages/dashboard/app/components/MemoryView.tsx   |  15 +-
 .../__tests__/KnowledgeGraphPanel.test.tsx         |  49 +++++++
 .../app/components/__tests__/MemoryView.test.tsx   |  13 ++
 .../app/hooks/__tests__/useKnowledgeGraph.test.ts  |  66 +++++++++
 packages/dashboard/app/hooks/useKnowledgeGraph.ts  |  94 ++++++++++++
 .../src/__tests__/knowledge-graph-access.test.ts   |  88 ++++++++++++
 packages/dashboard/src/knowledge-graph-access.ts   | 160 +++++++++++++++++++++
 .../register-knowledge-graph-routes.test.ts        |  75 ++++++++++
 .../src/routes/register-knowledge-routes.ts        | 120 ++++++++++++++++
 packages/i18n/locales/en/app.json                  |  15 +-
 packages/i18n/locales/es/app.json                  |  15 +-
 packages/i18n/locales/fr/app.json                  |  15 +-
 packages/i18n/locales/ko/app.json                  |  15 +-
 packages/i18n/locales/pt-BR/app.json               |  15 +-
 packages/i18n/locales/zh-CN/app.json               |  15 +-
 packages/i18n/locales/zh-TW/app.json               |  15 +-
 24 files changed, 939 insertions(+), 9 deletions(-)

Fusion-Task-Id: FN-9038

Fusion-Task-Lineage: 4200e214-ad22-4f92-b980-c9bcb8ffd3b9

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-13 16:48:01 -07:00

95 lines
5.3 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import {
buildKnowledgeGraphArtifacts,
fetchKnowledgeGraphNeighbors,
fetchKnowledgeGraphNode,
fetchKnowledgeGraphPath,
fetchKnowledgeGraphStatus,
queryKnowledgeGraphNodes,
type KnowledgeGraphNodeQuery,
type KnowledgeGraphPathResult,
} from "../api/system/knowledge-graph.js";
const EMPTY_FILTERS: KnowledgeGraphNodeQuery = { limit: 50, offset: 0 };
type NeighborOptions = { direction: "out" | "in" | "both"; edgeKinds?: string[]; depth: number };
/*
FNXC:KnowledgeGraphDashboard 2026-08-13-23:30:
Graph state is isolated and enabled only by the active tab, so Memory's existing tabs never parse or request the multi-megabyte graph artifacts. Request sequence fences retain the latest operator intent when debounced searches, node drills, or path requests resolve out of order.
*/
export function useKnowledgeGraph({ projectId, enabled }: { projectId?: string; enabled: boolean }) {
const [status, setStatus] = useState<Awaited<ReturnType<typeof fetchKnowledgeGraphStatus>> | null>(null);
const [filters, setFilters] = useState<KnowledgeGraphNodeQuery>(EMPTY_FILTERS);
const [nodes, setNodes] = useState<Awaited<ReturnType<typeof queryKnowledgeGraphNodes>> | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detail, setDetail] = useState<Awaited<ReturnType<typeof fetchKnowledgeGraphNode>> | null>(null);
const [neighborOptions, setNeighborOptions] = useState<NeighborOptions>({ direction: "both", depth: 1 });
const [neighborResults, setNeighborResults] = useState<Awaited<ReturnType<typeof fetchKnowledgeGraphNeighbors>> | null>(null);
const [pathInput, setPathInput] = useState({ fromId: "", toId: "", maxHops: 6 });
const [pathResult, setPathResult] = useState<KnowledgeGraphPathResult | null>(null);
const [loading, setLoading] = useState(false);
const [rebuilding, setRebuilding] = useState(false);
const [error, setError] = useState<string | null>(null);
const searchSequence = useRef(0);
const detailSequence = useRef(0);
const pathSequence = useRef(0);
const refreshStatus = useCallback(async () => {
try {
const value = await fetchKnowledgeGraphStatus(projectId);
setStatus(value);
setPathInput((input) => ({ ...input, maxHops: value.pathLimits.defaultMaxHops }));
} catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); }
}, [projectId]);
useEffect(() => { if (enabled) void refreshStatus(); }, [enabled, refreshStatus]);
useEffect(() => {
if (!enabled || !status?.available) return;
const current = ++searchSequence.current;
setLoading(true);
const timer = window.setTimeout(() => void queryKnowledgeGraphNodes(projectId, filters)
.then((value) => { if (current === searchSequence.current) setNodes(value); })
.catch((reason) => { if (current === searchSequence.current) setError(reason instanceof Error ? reason.message : String(reason)); })
.finally(() => { if (current === searchSequence.current) setLoading(false); }), 250);
return () => window.clearTimeout(timer);
}, [enabled, filters, projectId, status?.available]);
const refreshNeighbors = useCallback(async (id = selectedId) => {
if (!id) return;
const current = ++detailSequence.current;
try {
const value = await fetchKnowledgeGraphNeighbors(projectId, { nodeId: id, ...neighborOptions });
if (current === detailSequence.current) setNeighborResults(value);
} catch (reason) { if (current === detailSequence.current) setError(reason instanceof Error ? reason.message : String(reason)); }
}, [neighborOptions, projectId, selectedId]);
const selectNode = useCallback(async (id: string) => {
const current = ++detailSequence.current;
setSelectedId(id);
try {
const [node, neighborValue] = await Promise.all([
fetchKnowledgeGraphNode(projectId, id),
fetchKnowledgeGraphNeighbors(projectId, { nodeId: id, ...neighborOptions }),
]);
if (current === detailSequence.current) { setDetail(node); setNeighborResults(neighborValue); }
} catch (reason) { if (current === detailSequence.current) setError(reason instanceof Error ? reason.message : String(reason)); }
}, [neighborOptions, projectId]);
const findPath = useCallback(async () => {
const current = ++pathSequence.current;
setPathResult(null); setError(null);
try {
const result = await fetchKnowledgeGraphPath(projectId, pathInput);
if (current === pathSequence.current) setPathResult(result);
} catch (reason) { if (current === pathSequence.current) setError(reason instanceof Error ? reason.message : String(reason)); }
}, [pathInput, projectId]);
const rebuild = useCallback(async (force = false) => {
setRebuilding(true);
try { await buildKnowledgeGraphArtifacts(projectId, force); await refreshStatus(); }
catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); }
finally { setRebuilding(false); }
}, [projectId, refreshStatus]);
return { status, filters, setFilters, nodes, loading, selectedId, detail, selectNode, neighborOptions, setNeighborOptions, neighborResults, refreshNeighbors, pathInput, setPathInput, pathResult, findPath, rebuild, rebuilding, refreshStatus, error, clearFilters: () => setFilters(EMPTY_FILTERS) };
}