mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 02:15:57 +00:00
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:
1 parent
4fce73198d
commit
bf970c4ff2
9 files changed
+132
-59
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "没有与您搜索匹配的实体。",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in new issue
Block a user