feat(dashboard): add context-aware chat panel with Claude API integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lum1104
2026-03-14 19:20:46 +08:00
Unverified
parent c3bece91fc
commit bd53455f31
5 changed files with 373 additions and 12 deletions
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.78.0",
"@dagrejs/dagre": "^2.0.4",
"@monaco-editor/react": "^4.7.0",
"@understand-anything/core": "workspace:*",
+4 -3
View File
@@ -5,6 +5,7 @@ import GraphView from "./components/GraphView";
import CodeViewer from "./components/CodeViewer";
import SearchBar from "./components/SearchBar";
import NodeInfo from "./components/NodeInfo";
import ChatPanel from "./components/ChatPanel";
function App() {
const graph = useDashboardStore((s) => s.graph);
@@ -55,9 +56,9 @@ function App() {
<CodeViewer />
</div>
{/* Bottom-left: Chat placeholder */}
<div className="min-h-0 min-w-0 bg-gray-800 rounded-lg flex items-center justify-center">
<p className="text-gray-400 text-sm">Chat panel (coming soon)</p>
{/* Bottom-left: Chat */}
<div className="min-h-0 min-w-0">
<ChatPanel />
</div>
{/* Bottom-right: Node Info */}
@@ -0,0 +1,180 @@
import { useEffect, useRef, useState } from "react";
import { useDashboardStore } from "../store";
export default function ChatPanel() {
const graph = useDashboardStore((s) => s.graph);
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
const apiKey = useDashboardStore((s) => s.apiKey);
const chatMessages = useDashboardStore((s) => s.chatMessages);
const chatLoading = useDashboardStore((s) => s.chatLoading);
const setApiKey = useDashboardStore((s) => s.setApiKey);
const sendChatMessage = useDashboardStore((s) => s.sendChatMessage);
const clearChat = useDashboardStore((s) => s.clearChat);
const [input, setInput] = useState("");
const [keyInput, setKeyInput] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
const selectedNode = graph?.nodes.find((n) => n.id === selectedNodeId);
// Load API key from localStorage on mount
useEffect(() => {
const storedKey = localStorage.getItem("ua-api-key");
if (storedKey) {
setApiKey(storedKey);
}
}, [setApiKey]);
// Auto-scroll to latest message
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [chatMessages, chatLoading]);
const handleSend = () => {
if (!input.trim() || chatLoading || !apiKey) return;
sendChatMessage(input.trim());
setInput("");
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleSetKey = () => {
if (keyInput.trim()) {
setApiKey(keyInput.trim());
setKeyInput("");
}
};
const handleKeyInputKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleSetKey();
}
};
return (
<div className="h-full w-full bg-gray-800 rounded-lg flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-700 shrink-0">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
Chat
</h3>
{chatMessages.length > 0 && (
<button
onClick={clearChat}
className="text-[10px] text-gray-500 hover:text-gray-300 transition-colors"
>
Clear
</button>
)}
</div>
{/* API Key Input */}
{!apiKey && (
<div className="px-3 py-2 border-b border-gray-700 shrink-0">
<div className="flex gap-2">
<input
type="password"
value={keyInput}
onChange={(e) => setKeyInput(e.target.value)}
onKeyDown={handleKeyInputKeyDown}
placeholder="Enter Anthropic API key..."
className="flex-1 bg-gray-700 text-xs text-white rounded px-2 py-1.5 placeholder-gray-500 outline-none focus:ring-1 focus:ring-blue-500"
/>
<button
onClick={handleSetKey}
disabled={!keyInput.trim()}
className="text-xs bg-blue-600 text-white px-2 py-1.5 rounded hover:bg-blue-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Set
</button>
</div>
</div>
)}
{/* Context Indicator */}
{selectedNode && (
<div className="px-3 py-1.5 border-b border-gray-700 shrink-0">
<span className="text-[10px] text-gray-500">Context: </span>
<span className="text-[10px] text-blue-400">{selectedNode.name}</span>
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-2 min-h-0">
{chatMessages.length === 0 && (
<div className="flex items-center justify-center h-full">
<p className="text-gray-500 text-xs text-center">
{apiKey
? "Ask anything about this codebase"
: "Set your API key to start chatting"}
</p>
</div>
)}
{chatMessages.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[85%] rounded-lg px-3 py-2 text-xs leading-relaxed whitespace-pre-wrap ${
msg.role === "user"
? "bg-blue-600 text-white"
: "bg-gray-700 text-gray-200"
}`}
>
{msg.content}
</div>
</div>
))}
{chatLoading && (
<div className="flex justify-start">
<div className="bg-gray-700 rounded-lg px-3 py-2 text-xs text-gray-400">
<span className="inline-flex gap-1">
<span className="animate-pulse">Thinking</span>
<span className="animate-pulse delay-100">.</span>
<span className="animate-pulse delay-200">.</span>
<span className="animate-pulse delay-300">.</span>
</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="px-3 py-2 border-t border-gray-700 shrink-0">
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
apiKey
? "Ask about this codebase..."
: "Set API key first"
}
disabled={!apiKey || chatLoading}
className="flex-1 bg-gray-700 text-xs text-white rounded px-2 py-1.5 placeholder-gray-500 outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
/>
<button
onClick={handleSend}
disabled={!apiKey || chatLoading || !input.trim()}
className="text-xs bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Send
</button>
</div>
</div>
</div>
);
}
+149
View File
@@ -1,8 +1,14 @@
import Anthropic from "@anthropic-ai/sdk";
import { create } from "zustand";
import { SearchEngine } from "@understand-anything/core/search";
import type { SearchResult } from "@understand-anything/core/search";
import type { KnowledgeGraph } from "@understand-anything/core/types";
export interface ChatMessage {
role: "user" | "assistant";
content: string;
}
interface DashboardStore {
graph: KnowledgeGraph | null;
selectedNodeId: string | null;
@@ -10,9 +16,87 @@ interface DashboardStore {
searchResults: SearchResult[];
searchEngine: SearchEngine | null;
apiKey: string;
chatMessages: ChatMessage[];
chatLoading: boolean;
setGraph: (graph: KnowledgeGraph) => void;
selectNode: (nodeId: string | null) => void;
setSearchQuery: (query: string) => void;
setApiKey: (key: string) => void;
sendChatMessage: (message: string) => Promise<void>;
clearChat: () => void;
}
function buildSystemPrompt(
graph: KnowledgeGraph | null,
selectedNodeId: string | null,
): string {
const parts: string[] = [];
parts.push(
"You are an expert code assistant for the Understand Anything dashboard. " +
"You help users understand codebases by answering questions about the project structure, " +
"code relationships, and architecture. Be concise and helpful.",
);
if (graph) {
const { project, nodes, edges, layers } = graph;
parts.push(
`\n## Project: ${project.name}\n` +
`Description: ${project.description}\n` +
`Languages: ${project.languages.join(", ")}\n` +
`Frameworks: ${project.frameworks.join(", ")}\n` +
`Analyzed at: ${project.analyzedAt}`,
);
parts.push(
`\n## Graph Overview\n` +
`Nodes: ${nodes.length}\n` +
`Edges: ${edges.length}\n` +
`Layers: ${layers.map((l) => l.name).join(", ") || "none"}`,
);
// Include a summary of all nodes for context
const nodeSummaries = nodes
.map(
(n) =>
`- [${n.type}] ${n.name}${n.filePath ? ` (${n.filePath})` : ""}: ${n.summary}`,
)
.join("\n");
parts.push(`\n## All Nodes\n${nodeSummaries}`);
// Selected node details
if (selectedNodeId) {
const node = nodes.find((n) => n.id === selectedNodeId);
if (node) {
const connections = edges.filter(
(e) => e.source === node.id || e.target === node.id,
);
const connDetails = connections
.map((e) => {
const isSource = e.source === node.id;
const otherId = isSource ? e.target : e.source;
const otherNode = nodes.find((n) => n.id === otherId);
return ` ${isSource ? "->" : "<-"} [${e.type}] ${otherNode?.name ?? otherId}`;
})
.join("\n");
parts.push(
`\n## Currently Selected Node\n` +
`Name: ${node.name}\n` +
`Type: ${node.type}\n` +
`Summary: ${node.summary}\n` +
`File: ${node.filePath ?? "N/A"}\n` +
`Tags: ${node.tags.join(", ") || "none"}\n` +
`Complexity: ${node.complexity}\n` +
`Connections:\n${connDetails || " none"}`,
);
}
}
}
return parts.join("\n");
}
export const useDashboardStore = create<DashboardStore>()((set, get) => ({
@@ -22,6 +106,10 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
searchResults: [],
searchEngine: null,
apiKey: "",
chatMessages: [],
chatLoading: false,
setGraph: (graph) => {
const searchEngine = new SearchEngine(graph.nodes);
const query = get().searchQuery;
@@ -38,4 +126,65 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
const searchResults = engine.search(query);
set({ searchQuery: query, searchResults });
},
setApiKey: (key) => {
localStorage.setItem("ua-api-key", key);
set({ apiKey: key });
},
sendChatMessage: async (message) => {
const { apiKey, chatMessages, graph, selectedNodeId } = get();
if (!apiKey || !message.trim()) return;
const userMessage: ChatMessage = { role: "user", content: message };
set({
chatMessages: [...chatMessages, userMessage],
chatLoading: true,
});
try {
const client = new Anthropic({
apiKey,
dangerouslyAllowBrowser: true,
});
const systemPrompt = buildSystemPrompt(graph, selectedNodeId);
const response = await client.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 1024,
system: systemPrompt,
messages: [...chatMessages, userMessage].map((m) => ({
role: m.role,
content: m.content,
})),
});
const assistantContent =
response.content[0].type === "text"
? response.content[0].text
: "Unable to generate a response.";
const assistantMessage: ChatMessage = {
role: "assistant",
content: assistantContent,
};
set((state) => ({
chatMessages: [...state.chatMessages, assistantMessage],
chatLoading: false,
}));
} catch (err) {
const errorMessage: ChatMessage = {
role: "assistant",
content: `Error: ${err instanceof Error ? err.message : "Failed to get response. Please check your API key."}`,
};
set((state) => ({
chatMessages: [...state.chatMessages, errorMessage],
chatLoading: false,
}));
}
},
clearChat: () => set({ chatMessages: [] }),
}));
+39 -9
View File
@@ -45,6 +45,9 @@ importers:
packages/dashboard:
dependencies:
'@anthropic-ai/sdk':
specifier: ^0.78.0
version: 0.78.0(zod@4.3.6)
'@dagrejs/dagre':
specifier: ^2.0.4
version: 2.0.4
@@ -107,6 +110,15 @@ importers:
packages:
'@anthropic-ai/sdk@0.78.0':
resolution: {integrity: sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==}
hasBin: true
peerDependencies:
zod: ^3.25.0 || ^4.0.0
peerDependenciesMeta:
zod:
optional: true
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
@@ -178,6 +190,10 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/runtime@7.28.6':
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'}
'@babel/template@7.28.6':
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'}
@@ -1019,6 +1035,10 @@ packages:
engines: {node: '>=6'}
hasBin: true
json-schema-to-ts@3.1.1:
resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
engines: {node: '>=16'}
json5@2.2.3:
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'}
@@ -1245,6 +1265,9 @@ packages:
tree-sitter:
optional: true
ts-algebra@2.0.0:
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -1429,6 +1452,12 @@ packages:
snapshots:
'@anthropic-ai/sdk@0.78.0(zod@4.3.6)':
dependencies:
json-schema-to-ts: 3.1.1
optionalDependencies:
zod: 4.3.6
'@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -1518,6 +1547,8 @@ snapshots:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/runtime@7.28.6': {}
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.29.0
@@ -1968,14 +1999,6 @@ snapshots:
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)
'@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1))':
dependencies:
'@vitest/spy': 3.2.4
@@ -2209,6 +2232,11 @@ snapshots:
jsesc@3.1.0: {}
json-schema-to-ts@3.1.1:
dependencies:
'@babel/runtime': 7.28.6
ts-algebra: 2.0.0
json5@2.2.3: {}
lightningcss-android-arm64@1.31.1:
@@ -2394,6 +2422,8 @@ snapshots:
node-gyp-build: 4.8.4
tree-sitter-javascript: 0.23.1
ts-algebra@2.0.0: {}
typescript@5.9.3: {}
undici-types@6.21.0: {}
@@ -2512,7 +2542,7 @@ snapshots:
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1))
'@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4