feat(dashboard): add contextual node explanation with Claude API

Add "Explain This" button to NodeInfo panel that generates a detailed
plain-English explanation of the selected node using Claude API. Explanations
are cached per node ID and cleared on node switch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lum1104
2026-03-14 20:38:29 +08:00
co-authored by Claude Opus 4.6
parent 46f02ab7c6
commit 8747ed30b4
2 changed files with 136 additions and 1 deletions
@@ -1,3 +1,4 @@
import ReactMarkdown from "react-markdown";
import { useDashboardStore } from "../store";
const typeBadgeColors: Record<string, string> = {
@@ -17,6 +18,12 @@ const complexityBadgeColors: Record<string, string> = {
export default function NodeInfo() {
const graph = useDashboardStore((s) => s.graph);
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
const apiKey = useDashboardStore((s) => s.apiKey);
const nodeExplanation = useDashboardStore((s) => s.nodeExplanation);
const nodeExplanationLoading = useDashboardStore(
(s) => s.nodeExplanationLoading,
);
const explainNode = useDashboardStore((s) => s.explainNode);
const node = graph?.nodes.find((n) => n.id === selectedNodeId) ?? null;
@@ -75,6 +82,47 @@ export default function NodeInfo() {
</div>
)}
{apiKey && (
<div className="mb-4">
{!nodeExplanation && !nodeExplanationLoading && (
<button
onClick={() => explainNode(node.id)}
className="text-xs bg-indigo-600 text-white px-3 py-1.5 rounded hover:bg-indigo-500 transition-colors"
>
Explain This
</button>
)}
{nodeExplanationLoading && (
<div className="text-xs text-gray-400 animate-pulse">
Generating explanation...
</div>
)}
{nodeExplanation && (
<div className="bg-gray-700/50 rounded-lg p-3 text-sm text-gray-300 leading-relaxed">
<ReactMarkdown
components={{
p: ({ children }) => (
<p className="mb-2 last:mb-0">{children}</p>
),
strong: ({ children }) => (
<strong className="font-semibold text-white">
{children}
</strong>
),
code: ({ children }) => (
<code className="bg-gray-900 rounded px-1 py-0.5 text-[11px]">
{children}
</code>
),
}}
>
{nodeExplanation}
</ReactMarkdown>
</div>
)}
</div>
)}
{node.tags.length > 0 && (
<div className="mb-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
+88 -1
View File
@@ -25,6 +25,10 @@ interface DashboardStore {
showLayers: boolean;
nodeExplanation: string | null;
nodeExplanationLoading: boolean;
nodeExplanationCache: Record<string, string>;
tourActive: boolean;
currentTourStep: number;
tourHighlightedNodeIds: string[];
@@ -36,6 +40,7 @@ interface DashboardStore {
sendChatMessage: (message: string) => Promise<void>;
clearChat: () => void;
toggleLayers: () => void;
explainNode: (nodeId: string) => Promise<void>;
startTour: () => void;
stopTour: () => void;
@@ -132,6 +137,10 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
showLayers: false,
nodeExplanation: null,
nodeExplanationLoading: false,
nodeExplanationCache: {},
tourActive: false,
currentTourStep: 0,
tourHighlightedNodeIds: [],
@@ -142,7 +151,7 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
const searchResults = query.trim() ? searchEngine.search(query) : [];
set({ graph, searchEngine, searchResults });
},
selectNode: (nodeId) => set({ selectedNodeId: nodeId }),
selectNode: (nodeId) => set({ selectedNodeId: nodeId, nodeExplanation: null }),
setSearchQuery: (query) => {
const engine = get().searchEngine;
if (!engine || !query.trim()) {
@@ -216,6 +225,84 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
toggleLayers: () => set((state) => ({ showLayers: !state.showLayers })),
explainNode: async (nodeId) => {
const { apiKey, graph, nodeExplanationCache } = get();
if (!apiKey || !graph) return;
// Check cache first
if (nodeExplanationCache[nodeId]) {
set({ nodeExplanation: nodeExplanationCache[nodeId] });
return;
}
const node = graph.nodes.find((n) => n.id === nodeId);
if (!node) return;
set({ nodeExplanationLoading: true, nodeExplanation: null });
try {
const connections = graph.edges.filter(
(e) => e.source === nodeId || e.target === nodeId,
);
const connDetails = connections
.map((e) => {
const isSource = e.source === nodeId;
const otherId = isSource ? e.target : e.source;
const otherNode = graph.nodes.find((n) => n.id === otherId);
return `${isSource ? "->" : "<-"} [${e.type}] ${otherNode?.name ?? otherId}`;
})
.join("\n");
const layer = graph.layers.find((l) => l.nodeIds.includes(nodeId));
const prompt = [
`Explain the following code component in plain English. Be thorough but accessible.`,
``,
`**Component:** ${node.name}`,
`**Type:** ${node.type}`,
`**File:** ${node.filePath ?? "N/A"}`,
`**Summary:** ${node.summary}`,
`**Complexity:** ${node.complexity}`,
`**Tags:** ${node.tags.join(", ") || "none"}`,
layer ? `**Layer:** ${layer.name}${layer.description}` : "",
``,
`**Connections:**`,
connDetails || " none",
``,
`Explain:`,
`1. What this component does and WHY it exists`,
`2. How it fits into the larger architecture`,
`3. Key relationships with other components`,
`4. Any patterns or concepts worth understanding`,
``,
`Keep the explanation concise (2-4 paragraphs). Use markdown formatting.`,
].join("\n");
const client = new Anthropic({ apiKey, dangerouslyAllowBrowser: true });
const response = await client.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 512,
messages: [{ role: "user", content: prompt }],
});
const text =
response.content[0].type === "text"
? response.content[0].text
: "Unable to generate explanation.";
set((state) => ({
nodeExplanation: text,
nodeExplanationLoading: false,
nodeExplanationCache: { ...state.nodeExplanationCache, [nodeId]: text },
}));
} catch (err) {
set({
nodeExplanation: `Error: ${err instanceof Error ? err.message : "Failed to generate explanation"}`,
nodeExplanationLoading: false,
});
}
},
startTour: () => {
const { graph } = get();
if (!graph || graph.tour.length === 0) return;