feat(kg): separate Knowledge Graph page, auto-fit zoom, compact layout

Extract KG from Memory page into standalone /knowledge-graph route.
Fix graph zoom: fitView after D3 force simulation settles so all nodes
are visible. Compact layout: merge stats/search/actions into single row,
inline filters with page header.
This commit is contained in:
viettranx committed 2026-03-10 18:46:44 +07:00
1 parent 4fce73198d
commit bf970c4ff2
9 files changed
+132 -59

No files matched your search

+6 -1
View File
@@ -12,7 +12,8 @@
"agent": "Agent",
"allAgents": "All agents",
"scope": "Scope",
"allScope": "All (global + personal)"
"allScope": "All (global + personal)",
"selectAgent": "Select an agent"
},
"tabs": {
"documents": "Documents",
@@ -85,6 +86,10 @@
"path": "Path:"
},
"kg": {
"pageTitle": "Knowledge Graph",
"pageDescription": "Explore entities and relationships extracted from agent memory.",
"selectAgentTitle": "Select an agent",
"selectAgentDescription": "Choose an agent to view its knowledge graph.",
"emptyTitle": "No entities",
"emptyDescription": "No knowledge graph entities for this agent yet.",
"emptySearchDescription": "No entities match your search.",
+6 -1
View File
@@ -12,7 +12,8 @@
"agent": "Agent",
"allAgents": "Tất cả agent",
"scope": "Phạm vi",
"allScope": "Tất cả (toàn cục + cá nhân)"
"allScope": "Tất cả (toàn cục + cá nhân)",
"selectAgent": "Chọn agent"
},
"tabs": {
"documents": "Tài liệu",
@@ -85,6 +86,10 @@
"path": "Đường dẫn:"
},
"kg": {
"pageTitle": "Knowledge Graph",
"pageDescription": "Khám phá các thực thể và quan hệ được trích xuất từ bộ nhớ agent.",
"selectAgentTitle": "Chọn agent",
"selectAgentDescription": "Chọn agent để xem đồ thị tri thức.",
"emptyTitle": "Không có thực thể",
"emptyDescription": "Chưa có thực thể đồ thị tri thức nào cho agent này.",
"emptySearchDescription": "Không có thực thể nào khớp với tìm kiếm của bạn.",
+6 -1
View File
@@ -12,7 +12,8 @@
"agent": "Agent",
"allAgents": "所有Agent",
"scope": "范围",
"allScope": "全部(全局 + 个人)"
"allScope": "全部(全局 + 个人)",
"selectAgent": "选择Agent"
},
"tabs": {
"documents": "文档",
@@ -85,6 +86,10 @@
"path": "路径:"
},
"kg": {
"pageTitle": "Knowledge Graph",
"pageDescription": "探索从Agent记忆中提取的实体和关系。",
"selectAgentTitle": "选择Agent",
"selectAgentDescription": "选择Agent以查看其知识图谱。",
"emptyTitle": "暂无实体",
"emptyDescription": "该Agent暂无知识图谱实体。",
"emptySearchDescription": "没有与您搜索匹配的实体。",
+1
View File
@@ -33,6 +33,7 @@ export const ROUTES = {
STORAGE: "/storage",
PENDING_MESSAGES: "/pending-messages",
MEMORY: "/memory",
KNOWLEDGE_GRAPH: "/knowledge-graph",
SETUP: "/setup",
} as const;
@@ -0,0 +1,60 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Network } from "lucide-react";
import { EmptyState } from "@/components/shared/empty-state";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import { KGEntitiesTab } from "@/pages/memory/kg-entities-tab";
export function KnowledgeGraphPage() {
const { t } = useTranslation("memory");
const { agents } = useAgents();
const [agentId, setAgentId] = useState("");
const [userIdFilter, setUserIdFilter] = useState("");
return (
<div className="p-4 sm:p-6">
{/* Header + filters in one row */}
<div className="flex flex-wrap items-center gap-3">
<div className="mr-auto">
<h1 className="text-lg font-semibold">{t("kg.pageTitle")}</h1>
<p className="text-xs text-muted-foreground">{t("kg.pageDescription")}</p>
</div>
<select
id="kg-agent"
value={agentId}
onChange={(e) => { setAgentId(e.target.value); setUserIdFilter(""); }}
className="h-8 rounded-md border bg-background px-2 text-sm"
>
<option value="">{t("filters.selectAgent")}</option>
{agents.map((a) => (
<option key={a.id} value={a.id}>
{a.display_name || a.agent_key}
</option>
))}
</select>
{agentId && (
<input
id="kg-scope"
value={userIdFilter}
onChange={(e) => setUserIdFilter(e.target.value)}
placeholder={t("filters.allScope")}
className="h-8 rounded-md border bg-background px-2 text-sm w-[160px]"
/>
)}
</div>
{/* Content */}
<div className="mt-3">
{!agentId ? (
<EmptyState
icon={Network}
title={t("kg.selectAgentTitle")}
description={t("kg.selectAgentDescription")}
/>
) : (
<KGEntitiesTab agentId={agentId} userId={userIdFilter || undefined} />
)}
</div>
</div>
);
}
+23 -22
View File
@@ -61,34 +61,35 @@ export function KGEntitiesTab({ agentId, userId }: KGEntitiesTabProps) {
return (
<div>
{/* Stats bar */}
{stats && (
<div className="flex gap-4 text-xs text-muted-foreground mb-3">
<span>{t("kg.stats.entities", { count: stats.entity_count })}</span>
<span>{t("kg.stats.relations", { count: stats.relation_count })}</span>
{Object.entries(stats.entity_types).map(([type, count]) => (
<span key={type}>{type}: {count}</span>
))}
</div>
)}
{/* Search + actions */}
<div className="flex gap-2 mb-4">
{/* Search + stats + actions — single compact row */}
<div className="flex items-center gap-2 mb-3">
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("kg.search.placeholder")}
className="max-w-sm"
className="max-w-[220px] h-8 text-xs"
/>
<Button variant="outline" size="sm" onClick={handleSearch} disabled={fetching} className="gap-1 h-9">
<Search className="h-3.5 w-3.5" /> {t("kg.search.button")}
<Button variant="outline" size="sm" onClick={handleSearch} disabled={fetching} className="gap-1 h-8 px-2.5">
<Search className="h-3.5 w-3.5" />
</Button>
{appliedQuery && (
<Button variant="ghost" size="sm" onClick={() => { setAppliedQuery(""); setSearchQuery(""); }} className="h-9">
<Button variant="ghost" size="sm" onClick={() => { setAppliedQuery(""); setSearchQuery(""); }} className="h-8 px-2 text-xs">
{t("kg.search.clear")}
</Button>
)}
{/* Inline stats */}
{stats && (
<div className="flex gap-2 text-[10px] text-muted-foreground ml-1">
<span>{t("kg.stats.entities", { count: stats.entity_count })}</span>
<span>{t("kg.stats.relations", { count: stats.relation_count })}</span>
{Object.entries(stats.entity_types).map(([type, count]) => (
<span key={type}>{type}: {count}</span>
))}
</div>
)}
<div className="flex-1" />
{/* View mode toggle */}
@@ -97,7 +98,7 @@ export function KGEntitiesTab({ agentId, userId }: KGEntitiesTabProps) {
variant={viewMode === "table" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
className="h-9 rounded-r-none gap-1 px-2.5"
className="h-8 rounded-r-none px-2"
>
<LayoutGrid className="h-3.5 w-3.5" />
</Button>
@@ -105,16 +106,16 @@ export function KGEntitiesTab({ agentId, userId }: KGEntitiesTabProps) {
variant={viewMode === "graph" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("graph")}
className="h-9 rounded-l-none gap-1 px-2.5"
className="h-8 rounded-l-none px-2"
>
<Share2 className="h-3.5 w-3.5" />
</Button>
</div>
<Button variant="outline" size="sm" onClick={() => refresh()} disabled={fetching} className="gap-1 h-9">
<RefreshCw className={"h-3.5 w-3.5" + (fetching ? " animate-spin" : "")} /> {t("kg.refresh")}
<Button variant="outline" size="sm" onClick={() => refresh()} disabled={fetching} className="gap-1 h-8 px-2.5">
<RefreshCw className={"h-3.5 w-3.5" + (fetching ? " animate-spin" : "")} />
</Button>
<Button variant="outline" size="sm" onClick={() => setExtractOpen(true)} className="gap-1 h-9">
<Button variant="outline" size="sm" onClick={() => setExtractOpen(true)} className="gap-1 h-8 px-2.5">
<Sparkles className="h-3.5 w-3.5" /> {t("kg.extract")}
</Button>
</div>
+24 -5
View File
@@ -1,8 +1,10 @@
import { useMemo, useEffect, useCallback, useRef } from "react";
import {
ReactFlow,
ReactFlowProvider,
useNodesState,
useEdgesState,
useReactFlow,
Background,
Controls,
MiniMap,
@@ -88,6 +90,7 @@ function applyForceLayout(
nodes: Node[],
edges: Edge[],
onUpdate: (positioned: Node[]) => void,
onSettled?: () => void,
) {
if (nodes.length === 0) return () => {};
@@ -113,6 +116,8 @@ function applyForceLayout(
onUpdate(positioned);
});
simulation.on("end", () => onSettled?.());
// Run fast to settle
simulation.alpha(1).restart();
@@ -125,8 +130,17 @@ interface KGGraphViewProps {
onEntityClick?: (entity: KGEntity) => void;
}
export function KGGraphView({ entities, relations, onEntityClick }: KGGraphViewProps) {
export function KGGraphView(props: KGGraphViewProps) {
return (
<ReactFlowProvider>
<KGGraphViewInner {...props} />
</ReactFlowProvider>
);
}
function KGGraphViewInner({ entities, relations, onEntityClick }: KGGraphViewProps) {
const { t } = useTranslation("memory");
const { fitView } = useReactFlow();
const { nodes: initialNodes, edges: initialEdges } = useMemo(
() => buildGraph(entities, relations),
[entities, relations],
@@ -136,13 +150,18 @@ export function KGGraphView({ entities, relations, onEntityClick }: KGGraphViewP
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const simRef = useRef<(() => void) | null>(null);
// Fit view after simulation settles so the graph covers the viewport
const handleSettled = useCallback(() => {
requestAnimationFrame(() => fitView({ padding: 0.15, duration: 300 }));
}, [fitView]);
// Run force layout when data changes
useEffect(() => {
setEdges(initialEdges);
simRef.current?.();
simRef.current = applyForceLayout(initialNodes, initialEdges, setNodes);
simRef.current = applyForceLayout(initialNodes, initialEdges, setNodes, handleSettled);
return () => simRef.current?.();
}, [initialNodes, initialEdges, setNodes, setEdges]);
}, [initialNodes, initialEdges, setNodes, setEdges, handleSettled]);
const handleNodeClick = useCallback(
(_: React.MouseEvent, node: Node) => {
@@ -171,8 +190,8 @@ export function KGGraphView({ entities, relations, onEntityClick }: KGGraphViewP
onNodeClick={handleNodeClick}
nodeTypes={nodeTypes}
fitView
minZoom={0.2}
maxZoom={2}
minZoom={0.1}
maxZoom={3}
proOptions={{ hideAttribution: true }}
>
<Background gap={20} size={1} />
+2 -29
View File
@@ -1,6 +1,6 @@
import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Brain, Plus, RefreshCw, Search, Database, Trash2, RotateCw, Network } from "lucide-react";
import { Brain, Plus, RefreshCw, Search, Database, Trash2, RotateCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
@@ -14,7 +14,6 @@ import { useMemoryDocuments } from "./hooks/use-memory";
import { MemoryDocumentDialog } from "./memory-document-dialog";
import { MemoryCreateDialog } from "./memory-create-dialog";
import { MemorySearchDialog } from "./memory-search-dialog";
import { KGEntitiesTab } from "./kg-entities-tab";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import type { MemoryDocument } from "@/types/memory";
@@ -31,7 +30,6 @@ export function MemoryPage() {
const [deleteLoading, setDeleteLoading] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const [indexAllLoading, setIndexAllLoading] = useState(false);
const [activeTab, setActiveTab] = useState<"documents" | "kg">("documents");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
@@ -172,33 +170,8 @@ export function MemoryPage() {
)}
</div>
{/* Tab bar (only when agent selected) */}
{agentId && (
<div className="mt-4 flex border-b">
<button
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${activeTab === "documents" ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"}`}
onClick={() => setActiveTab("documents")}
>
<Brain className="inline h-3.5 w-3.5 mr-1.5" />{t("tabs.documents")}
</button>
<button
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${activeTab === "kg" ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"}`}
onClick={() => setActiveTab("kg")}
>
<Network className="inline h-3.5 w-3.5 mr-1.5" />{t("tabs.knowledgeGraph")}
</button>
</div>
)}
{/* KG tab */}
{agentId && activeTab === "kg" && (
<div className="mt-4">
<KGEntitiesTab agentId={agentId} userId={userIdFilter || undefined} />
</div>
)}
{/* Document table */}
<div className="mt-4" style={{ display: activeTab === "documents" || !agentId ? undefined : "none" }}>
<div className="mt-4">
{showSkeleton ? (
<TableSkeleton rows={5} />
) : documents.length === 0 ? (
+4
View File
@@ -84,6 +84,9 @@ const PendingMessagesPage = lazy(() =>
const MemoryPage = lazy(() =>
import("@/pages/memory/memory-page").then((m) => ({ default: m.MemoryPage })),
);
const KnowledgeGraphPage = lazy(() =>
import("@/pages/knowledge-graph/knowledge-graph-page").then((m) => ({ default: m.KnowledgeGraphPage })),
);
const ContactsPage = lazy(() =>
import("@/pages/contacts/contacts-page").then((m) => ({ default: m.ContactsPage })),
);
@@ -156,6 +159,7 @@ export function AppRoutes() {
<Route path={ROUTES.STORAGE} element={<StoragePage />} />
<Route path={ROUTES.PENDING_MESSAGES} element={<PendingMessagesPage />} />
<Route path={ROUTES.MEMORY} element={<MemoryPage />} />
<Route path={ROUTES.KNOWLEDGE_GRAPH} element={<KnowledgeGraphPage />} />
</Route>
{/* Catch-all → overview */}