mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-16 16:15:40 +00:00
fix(ui): reduce KG graph INP from 808ms to <100ms (#581)
- Defer dialog open via setTimeout(0) on node click — decouples edge
restyle from dialog mount (808ms → <100ms INP)
- Memoize relations table with React.memo + limit initial render to 50
rows with "Show all" button (792ms table cell INP → <100ms)
- Added i18n key kg.entity.showAll for en/vi/zh
Based on upstream review fixes (989e4f3).
This commit is contained in:
1 parent
989e4f3123
commit
0ef30ad060
5 files changed
+79
-36
No files matched your search
@@ -147,6 +147,7 @@
|
||||
"traversing": "Traversing...",
|
||||
"loading": "Loading...",
|
||||
"noRelations": "No relations found.",
|
||||
"showAll": "Show all",
|
||||
"traversalResults": "Traversal Results ({{count}})",
|
||||
"direction": {
|
||||
"outgoing": "→ outgoing",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
"traversing": "遍历中...",
|
||||
"loading": "加载中...",
|
||||
"noRelations": "未找到关系。",
|
||||
"showAll": "显示全部",
|
||||
"traversalResults": "遍历结果({{count}})",
|
||||
"direction": {
|
||||
"outgoing": "→ 出向",
|
||||
|
||||
@@ -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 ? (
|
||||
<p className="text-xs text-muted-foreground">{t("kg.entity.noRelations")}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full min-w-[400px] text-xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-3 py-2 text-left font-medium">{t("kg.entity.columns.direction")}</th>
|
||||
<th className="px-3 py-2 text-left font-medium">{t("kg.entity.columns.relation")}</th>
|
||||
<th className="px-3 py-2 text-left font-medium">{t("kg.entity.columns.target")}</th>
|
||||
<th className="px-3 py-2 text-left font-medium">{t("kg.entity.columns.confidence")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{relations.map((rel) => (
|
||||
<tr key={rel.id} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-3 py-2">
|
||||
{rel.source_entity_id === entity?.id
|
||||
? t("kg.entity.direction.outgoing")
|
||||
: t("kg.entity.direction.incoming")}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono">{rel.relation_type}</td>
|
||||
<td className="px-3 py-2 font-mono text-muted-foreground">
|
||||
{rel.source_entity_id === entity?.id ? rel.target_entity_id.slice(0, 8) : rel.source_entity_id.slice(0, 8)}
|
||||
</td>
|
||||
<td className="px-3 py-2">{Math.round(rel.confidence * 100)}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<RelationsTable relations={relations} entityId={entity?.id} t={t} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -168,3 +141,58 @@ export function KGEntityDetailDialog({ open, onOpenChange, agentId, entity, getE
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full min-w-[400px] text-xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-3 py-2 text-left font-medium">{t("kg.entity.columns.direction")}</th>
|
||||
<th className="px-3 py-2 text-left font-medium">{t("kg.entity.columns.relation")}</th>
|
||||
<th className="px-3 py-2 text-left font-medium">{t("kg.entity.columns.target")}</th>
|
||||
<th className="px-3 py-2 text-left font-medium">{t("kg.entity.columns.confidence")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visible.map((rel) => (
|
||||
<tr key={rel.id} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-3 py-2">
|
||||
{rel.source_entity_id === entityId
|
||||
? t("kg.entity.direction.outgoing")
|
||||
: t("kg.entity.direction.incoming")}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono">{rel.relation_type}</td>
|
||||
<td className="px-3 py-2 font-mono text-muted-foreground">
|
||||
{rel.source_entity_id === entityId ? rel.target_entity_id.slice(0, 8) : rel.source_entity_id.slice(0, 8)}
|
||||
</td>
|
||||
<td className="px-3 py-2">{Math.round(rel.confidence * 100)}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{hasMore && !showAll && (
|
||||
<button
|
||||
onClick={() => setShowAll(true)}
|
||||
className="w-full py-1.5 text-xs text-muted-foreground hover:text-foreground border-t"
|
||||
>
|
||||
{t("kg.entity.showAll")} ({relations.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in new issue
Block a user