fix(kg): fix filter reload, graph perf, stats layout, double-click interaction

- Fix filter not reloading: remove placeholderData from entity list and
  graph queries so data clears immediately on filter change; add key prop
  to force remount on filter change
- Fix graph lag/crash: reduce GRAPH_LIMIT to 80, increase Barnes-Hut theta
  for faster approximation, increase alphaDecay for faster convergence
- Move entity type stats to separate line below toolbar (better readability)
- Change node click: single-click highlights edges, double-click opens detail
- Increase entity list limit to 200 (table view only, graph stays at 80)
This commit is contained in:
viettranx committed 2026-04-03 18:42:05 +07:00
1 parent c2bd5349e5
commit 9e037bca8a
4 files changed
+33 -28

No files matched your search

@@ -96,7 +96,7 @@ export function KnowledgeGraphPage() {
}
/>
) : (
<KGEntitiesTab agentId={agentId} userId={userIdFilter || undefined} />
<KGEntitiesTab key={`${agentId}-${userIdFilter}`} agentId={agentId} userId={userIdFilter || undefined} />
)}
</div>
</div>
@@ -27,11 +27,10 @@ export function useKnowledgeGraph(filters: KGFilters) {
if (filters.userId) params.user_id = filters.userId;
if (filters.entityType) params.type = filters.entityType;
if (filters.query) params.q = filters.query;
params.limit = "100";
params.limit = "200";
return (await http.get<KGEntity[]>(`/v1/agents/${filters.agentId}/kg/entities`, params)) ?? [];
},
enabled: !!filters.agentId,
placeholderData: (prev) => prev,
});
const entities = data ?? [];
@@ -141,12 +140,11 @@ export function useKGGraph(agentId: string, userId?: string) {
queryKey: queryKeys.kg.graph(agentId, userId),
queryFn: async () => {
if (!agentId) return { entities: [], relations: [] };
const params: Record<string, string> = { limit: "200" };
const params: Record<string, string> = { limit: "80" };
if (userId) params.user_id = userId;
return http.get<KGGraphData>(`/v1/agents/${agentId}/kg/graph`, params);
},
enabled: !!agentId,
placeholderData: (prev) => prev,
});
return {
+15 -14
View File
@@ -63,8 +63,8 @@ export function KGEntitiesTab({ agentId, userId }: KGEntitiesTabProps) {
return (
<div className="flex h-full flex-col">
{/* Search + stats + actions — single compact row */}
<div className="flex items-center gap-2 mb-3">
{/* Toolbar row: search + actions */}
<div className="flex items-center gap-2 mb-2">
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
@@ -81,17 +81,6 @@ export function KGEntitiesTab({ agentId, userId }: KGEntitiesTabProps) {
</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 */}
@@ -115,7 +104,7 @@ export function KGEntitiesTab({ agentId, userId }: KGEntitiesTabProps) {
</div>
<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" : "")} />
<RefreshCw className={`h-3.5 w-3.5${fetching ? " animate-spin" : ""}`} />
</Button>
<Button variant="outline" size="sm" onClick={() => setDedupOpen(true)} className="gap-1 h-8 px-2.5">
<Merge className="h-3.5 w-3.5" /> {t("kg.dedup.button")}
@@ -125,6 +114,18 @@ export function KGEntitiesTab({ agentId, userId }: KGEntitiesTabProps) {
</Button>
</div>
{/* Stats row — separate line for entity type breakdown */}
{stats && (
<div className="flex flex-wrap gap-x-3 gap-y-1 mb-3 text-[10px] text-muted-foreground">
<span className="font-medium">{t("kg.stats.entities", { count: stats.entity_count })}</span>
<span className="font-medium">{t("kg.stats.relations", { count: stats.relation_count })}</span>
<span className="text-muted-foreground/50">|</span>
{Object.entries(stats.entity_types).map(([type, count]) => (
<span key={type}>{type}: {count}</span>
))}
</div>
)}
{/* Content area */}
<div className="min-h-0 flex-1">
{viewMode === "graph" ? (
+15 -9
View File
@@ -19,7 +19,7 @@ import { useTranslation } from "react-i18next";
import { useUiStore } from "@/stores/use-ui-store";
import type { KGEntity, KGRelation } from "@/types/knowledge-graph";
const GRAPH_LIMIT = 200;
const GRAPH_LIMIT = 80;
// Dual-theme palette — separate dark/light values for readability on both backgrounds
interface TypeColor {
@@ -124,18 +124,20 @@ function computeForceLayout(nodes: Node[], edges: Edge[], entities: KGEntity[]):
const chargeMul = n > 150 ? -350 : n > 100 ? -300 : n > 60 ? -250 : n > 30 ? -180 : -120;
const centerPull = n > 150 ? 0.015 : n > 100 ? 0.02 : n > 60 ? 0.03 : n > 30 ? 0.05 : 0.08;
const collideBase = n > 150 ? 60 : n > 100 ? 55 : n > 60 ? 50 : n > 30 ? 40 : 35;
// Barnes-Hut theta: higher = faster approximation, slightly less accurate
const theta = n > 60 ? 1.2 : 0.9;
const charge = forceManyBody().strength((d: any) => chargeMul * (d.mass ?? 1)).theta(theta);
const simulation = forceSimulation(simNodes)
.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("charge", charge)
.force("center", forceCenter(w / 2, h / 2))
.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))
.alphaDecay(n > 60 ? 0.05 : 0.0228) // faster convergence for large graphs
.stop();
const ticks = Math.ceil(Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay()));
// Cap ticks for large graphs — diminishing returns past 200 iterations
const maxTicks = n > 100 ? 200 : ticks;
for (let i = 0; i < Math.min(ticks, maxTicks); i++) simulation.tick();
for (let i = 0; i < ticks; i++) simulation.tick();
return nodes.map((n, i) => ({ ...n, position: { x: simNodes[i]!.x ?? 0, y: simNodes[i]!.y ?? 0 } }));
}
@@ -252,12 +254,16 @@ function KGGraphViewInner({ entities: allEntities, relations: allRelations, onEn
const [, startTransition] = useTransition();
// Single click: highlight connected edges only
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
}, [startTransition]);
// Double click: open entity detail modal
const handleNodeDoubleClick = useCallback((_: React.MouseEvent, node: Node) => {
const entity = entityMap.get(node.id);
if (entity && onEntityClick) setTimeout(() => onEntityClick(entity), 0);
}, [entityMap, onEntityClick, startTransition]);
if (entity && onEntityClick) onEntityClick(entity);
}, [entityMap, onEntityClick]);
const handlePaneClick = useCallback(() => {
startTransition(() => setSelectedNodeId(null));
@@ -277,7 +283,7 @@ function KGGraphViewInner({ entities: allEntities, relations: allRelations, onEn
<ReactFlow
nodes={nodes} edges={edges}
onNodesChange={onNodesChange} onEdgesChange={onEdgesChange}
onNodeClick={handleNodeClick} onPaneClick={handlePaneClick}
onNodeClick={handleNodeClick} onNodeDoubleClick={handleNodeDoubleClick} onPaneClick={handlePaneClick}
nodeTypes={nodeTypes} colorMode={colorMode}
minZoom={0.1} maxZoom={3}
nodesConnectable={false} nodesDraggable={true} elementsSelectable={true}