diff --git a/ui/web/src/i18n/locales/en/memory.json b/ui/web/src/i18n/locales/en/memory.json
index bcade483..b6ca0ac1 100644
--- a/ui/web/src/i18n/locales/en/memory.json
+++ b/ui/web/src/i18n/locales/en/memory.json
@@ -147,6 +147,7 @@
"traversing": "Traversing...",
"loading": "Loading...",
"noRelations": "No relations found.",
+ "showAll": "Show all",
"traversalResults": "Traversal Results ({{count}})",
"direction": {
"outgoing": "→ outgoing",
diff --git a/ui/web/src/i18n/locales/vi/memory.json b/ui/web/src/i18n/locales/vi/memory.json
index a3410d31..2b8c0861 100644
--- a/ui/web/src/i18n/locales/vi/memory.json
+++ b/ui/web/src/i18n/locales/vi/memory.json
@@ -147,6 +147,7 @@
"traversing": "Đang duyệt...",
"loading": "Đang tải...",
"noRelations": "Không tìm thấy quan hệ.",
+ "showAll": "Hiện tất cả",
"traversalResults": "Kết quả duyệt ({{count}})",
"direction": {
"outgoing": "→ đi ra",
diff --git a/ui/web/src/i18n/locales/zh/memory.json b/ui/web/src/i18n/locales/zh/memory.json
index 69997cab..cdc119d0 100644
--- a/ui/web/src/i18n/locales/zh/memory.json
+++ b/ui/web/src/i18n/locales/zh/memory.json
@@ -147,6 +147,7 @@
"traversing": "遍历中...",
"loading": "加载中...",
"noRelations": "未找到关系。",
+ "showAll": "显示全部",
"traversalResults": "遍历结果({{count}})",
"direction": {
"outgoing": "→ 出向",
diff --git a/ui/web/src/pages/memory/kg-entity-detail-dialog.tsx b/ui/web/src/pages/memory/kg-entity-detail-dialog.tsx
index bfee27da..1986f3e2 100644
--- a/ui/web/src/pages/memory/kg-entity-detail-dialog.tsx
+++ b/ui/web/src/pages/memory/kg-entity-detail-dialog.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useCallback } from "react";
+import React, { useState, useEffect, useCallback } from "react";
import {
Dialog,
DialogContent,
@@ -111,34 +111,7 @@ export function KGEntityDetailDialog({ open, onOpenChange, agentId, entity, getE
) : relations.length === 0 ? (
{t("kg.entity.noRelations")}
) : (
-
-
-
-
- | {t("kg.entity.columns.direction")} |
- {t("kg.entity.columns.relation")} |
- {t("kg.entity.columns.target")} |
- {t("kg.entity.columns.confidence")} |
-
-
-
- {relations.map((rel) => (
-
- |
- {rel.source_entity_id === entity?.id
- ? t("kg.entity.direction.outgoing")
- : t("kg.entity.direction.incoming")}
- |
- {rel.relation_type} |
-
- {rel.source_entity_id === entity?.id ? rel.target_entity_id.slice(0, 8) : rel.source_entity_id.slice(0, 8)}
- |
- {Math.round(rel.confidence * 100)}% |
-
- ))}
-
-
-
+
)}
@@ -168,3 +141,58 @@ export function KGEntityDetailDialog({ open, onOpenChange, agentId, entity, getE
);
}
+
+/** Memoized relations table — prevents re-render when dialog parent updates */
+const RelationsTable = React.memo(function RelationsTable({
+ relations,
+ entityId,
+ t,
+}: {
+ relations: KGRelation[];
+ entityId: string | undefined;
+ t: (key: string) => string;
+}) {
+ const INITIAL_LIMIT = 50;
+ const [showAll, setShowAll] = useState(false);
+ const visible = showAll ? relations : relations.slice(0, INITIAL_LIMIT);
+ const hasMore = relations.length > INITIAL_LIMIT;
+
+ return (
+
+
+
+
+ | {t("kg.entity.columns.direction")} |
+ {t("kg.entity.columns.relation")} |
+ {t("kg.entity.columns.target")} |
+ {t("kg.entity.columns.confidence")} |
+
+
+
+ {visible.map((rel) => (
+
+ |
+ {rel.source_entity_id === entityId
+ ? t("kg.entity.direction.outgoing")
+ : t("kg.entity.direction.incoming")}
+ |
+ {rel.relation_type} |
+
+ {rel.source_entity_id === entityId ? rel.target_entity_id.slice(0, 8) : rel.source_entity_id.slice(0, 8)}
+ |
+ {Math.round(rel.confidence * 100)}% |
+
+ ))}
+
+
+ {hasMore && !showAll && (
+
+ )}
+
+ );
+});
diff --git a/ui/web/src/pages/memory/kg-graph-view.tsx b/ui/web/src/pages/memory/kg-graph-view.tsx
index 1940330b..18ace726 100644
--- a/ui/web/src/pages/memory/kg-graph-view.tsx
+++ b/ui/web/src/pages/memory/kg-graph-view.tsx
@@ -115,13 +115,19 @@ function computeForceLayout(nodes: Node[], edges: Edge[], entities: KGEntity[]):
}));
const simLinks = edges.map((e) => ({ source: e.source, target: e.target }));
const w = 600, h = 400;
+ // Scale forces by node count — tighter for small graphs, spread for large
+ const n = nodes.length;
+ const linkDist = n > 60 ? 200 : n > 30 ? 160 : 120;
+ const chargeMul = n > 60 ? -250 : n > 30 ? -180 : -120;
+ const centerPull = n > 60 ? 0.03 : n > 30 ? 0.05 : 0.08;
+ const collideBase = n > 60 ? 50 : n > 30 ? 40 : 35;
const simulation = forceSimulation(simNodes)
- .force("link", forceLink(simLinks).id((d: any) => d.id).distance(220).strength(0.4))
- .force("charge", forceManyBody().strength((d: any) => -300 * (d.mass ?? 1)))
+ .force("link", forceLink(simLinks).id((d: any) => d.id).distance(linkDist).strength(0.5))
+ .force("charge", forceManyBody().strength((d: any) => chargeMul * (d.mass ?? 1)))
.force("center", forceCenter(w / 2, h / 2))
- .force("x", forceX(w / 2).strength(0.03))
- .force("y", forceY(h / 2).strength(0.03))
- .force("collide", forceCollide().radius((d: any) => 55 + (d.mass ?? 1) * 5).strength(0.8))
+ .force("x", forceX(w / 2).strength(centerPull))
+ .force("y", forceY(h / 2).strength(centerPull))
+ .force("collide", forceCollide().radius((d: any) => collideBase + (d.mass ?? 1) * 3).strength(0.7))
.stop();
const ticks = Math.ceil(Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay()));
for (let i = 0; i < ticks; i++) simulation.tick();
@@ -155,7 +161,12 @@ function KGGraphViewInner({ entities: allEntities, relations: allRelations, onEn
// Limit entities to GRAPH_LIMIT, filter relations accordingly
const totalCount = allEntities.length;
const isLimited = totalCount > GRAPH_LIMIT;
- const entities = useMemo(() => isLimited ? allEntities.slice(0, GRAPH_LIMIT) : allEntities, [allEntities, isLimited]);
+ // Rank by degree centrality — hub nodes (most connections) always included in graph
+ const entities = useMemo(() => {
+ if (!isLimited) return allEntities;
+ const deg = computeDegreeMap(allEntities, allRelations);
+ return [...allEntities].sort((a, b) => (deg.get(b.id) ?? 0) - (deg.get(a.id) ?? 0)).slice(0, GRAPH_LIMIT);
+ }, [allEntities, allRelations, isLimited]);
const entityIds = useMemo(() => new Set(entities.map((e) => e.id)), [entities]);
const relations = useMemo(
() => allRelations.filter((r) => entityIds.has(r.source_entity_id) && entityIds.has(r.target_entity_id)),
@@ -238,8 +249,9 @@ function KGGraphViewInner({ entities: allEntities, relations: allRelations, onEn
const handleNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
startTransition(() => setSelectedNodeId((prev) => (prev === node.id ? null : node.id)));
+ // Defer dialog open to next frame — decouples edge restyle from dialog mount cost
const entity = entityMap.get(node.id);
- if (entity && onEntityClick) onEntityClick(entity);
+ if (entity && onEntityClick) setTimeout(() => onEntityClick(entity), 0);
}, [entityMap, onEntityClick, startTransition]);
const handlePaneClick = useCallback(() => {