refactor(ui): Phase 4 — adapter layer for data transformations

Create src/adapters/ with 3 adapter files:
- kg-graph.adapter.ts: KGEntity/KGRelation → GraphData (nodes, links,
  degree computation, color mapping, entity limiter)
- chat-message.adapter.ts: moved from chat/hooks/chat-message-transformer.ts
- trace.adapter.ts: buildSpanTree extracted from trace-span-tree-node

Skipped team-task (already in board-utils) and provider-pool (<10 lines,
single call site).

Inline transforms in kg-graph-view.tsx and trace-detail-dialog.tsx
replaced with adapter calls. No behavior changes.
This commit is contained in:
viettranx committed 2026-04-03 22:54:40 +07:00
1 parent 7e62623123
commit 1313f02cf4
7 files changed
+135 -98

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
import type { KGEntity, KGRelation } from "@/types/knowledge-graph";
// Solid colors per entity type — used for both circle fill and glow
export const KG_TYPE_COLORS: Record<string, string> = {
person: "#E85D24", organization: "#ef4444", project: "#22c55e",
product: "#f97316", technology: "#3b82f6", task: "#f59e0b",
event: "#ec4899", document: "#8b5cf6", concept: "#a78bfa", location: "#14b8a6",
};
export const KG_DEFAULT_COLOR = "#9ca3af";
export interface GraphNode {
id: string;
name: string;
entityType: string;
color: string;
neighbors: Set<string>;
linkIds: Set<string>;
degree: number;
// Force-graph adds these at runtime
x?: number;
y?: number;
}
export interface GraphLink {
id: string;
source: string;
target: string;
label: string;
}
export interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
}
/** Compute degree (edge count) for each entity id. */
export function computeDegreeMap(entities: KGEntity[], relations: KGRelation[]): Map<string, number> {
const deg = new Map<string, number>();
const ids = new Set(entities.map((e) => e.id));
for (const r of relations) {
if (ids.has(r.source_entity_id)) deg.set(r.source_entity_id, (deg.get(r.source_entity_id) ?? 0) + 1);
if (ids.has(r.target_entity_id)) deg.set(r.target_entity_id, (deg.get(r.target_entity_id) ?? 0) + 1);
}
return deg;
}
/**
* Transform KGEntity[] + KGRelation[] into react-force-graph GraphData.
* Builds nodes with color/degree, links with neighbor/linkId sets populated.
*/
export function buildGraphData(entities: KGEntity[], allRelations: KGRelation[]): GraphData {
const entityIds = new Set(entities.map((e) => e.id));
const degreeMap = computeDegreeMap(entities, allRelations);
const nodes: GraphNode[] = entities.map((e) => ({
id: e.id,
name: e.name,
entityType: e.entity_type,
color: KG_TYPE_COLORS[e.entity_type] ?? KG_DEFAULT_COLOR,
neighbors: new Set<string>(),
linkIds: new Set<string>(),
degree: degreeMap.get(e.id) ?? 0,
}));
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
const links: GraphLink[] = allRelations
.filter((r) => entityIds.has(r.source_entity_id) && entityIds.has(r.target_entity_id))
.map((r) => {
nodeMap.get(r.source_entity_id)?.neighbors.add(r.target_entity_id);
nodeMap.get(r.target_entity_id)?.neighbors.add(r.source_entity_id);
nodeMap.get(r.source_entity_id)?.linkIds.add(r.id);
nodeMap.get(r.target_entity_id)?.linkIds.add(r.id);
return {
id: r.id,
source: r.source_entity_id,
target: r.target_entity_id,
label: r.relation_type.replace(/_/g, " "),
};
});
return { nodes, links };
}
/**
* Limit entities to nodeLimit by degree centrality (highest-degree first).
*/
export function limitEntitiesByDegree(
allEntities: KGEntity[],
allRelations: KGRelation[],
nodeLimit: number,
): KGEntity[] {
if (allEntities.length <= nodeLimit) return allEntities;
const deg = computeDegreeMap(allEntities, allRelations);
return [...allEntities].sort((a, b) => (deg.get(b.id) ?? 0) - (deg.get(a.id) ?? 0)).slice(0, nodeLimit);
}
+22
View File
@@ -0,0 +1,22 @@
import type { SpanData } from "@/types/trace";
export interface SpanNode {
span: SpanData;
children: SpanNode[];
}
/** Build a parent→children tree from a flat span list. */
export function buildSpanTree(spans: SpanData[]): SpanNode[] {
const map = new Map<string, SpanNode>();
const roots: SpanNode[] = [];
for (const span of spans) map.set(span.id, { span, children: [] });
for (const span of spans) {
const node = map.get(span.id)!;
if (span.parent_span_id && map.has(span.parent_span_id)) {
map.get(span.parent_span_id)!.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
@@ -5,7 +5,7 @@ import { Methods, Events } from "@/api/protocol";
import type { Message } from "@/types/session";
import type { ChatMessage, AgentEventPayload, ToolStreamEntry, RunActivity, ActiveTeamTask, MediaItem } from "@/types/chat";
import { toFileUrl, mediaKindFromMime } from "@/lib/file-helpers";
import { transformHistoryMessages } from "./chat-message-transformer";
import { transformHistoryMessages } from "@/adapters/chat-message.adapter";
import { useChatTeamTasks } from "./use-chat-team-tasks";
/**
+10 -76
View File
@@ -5,50 +5,14 @@ import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import { useUiStore } from "@/stores/use-ui-store";
import type { KGEntity, KGRelation } from "@/types/knowledge-graph";
import { buildGraphData, limitEntitiesByDegree } from "@/adapters/kg-graph.adapter";
import type { GraphNode, GraphLink } from "@/adapters/kg-graph.adapter";
const NODE_LIMIT_OPTIONS = [100, 200, 300, 500] as const;
const DEFAULT_NODE_LIMIT = 200;
const NODE_R = 5;
const DOUBLE_CLICK_MS = 280;
// Solid colors per entity type — used for both circle fill and glow
const TYPE_COLORS: Record<string, string> = {
person: "#E85D24", organization: "#ef4444", project: "#22c55e",
product: "#f97316", technology: "#3b82f6", task: "#f59e0b",
event: "#ec4899", document: "#8b5cf6", concept: "#a78bfa", location: "#14b8a6",
};
const DEFAULT_COLOR = "#9ca3af";
function computeDegreeMap(entities: KGEntity[], relations: KGRelation[]): Map<string, number> {
const deg = new Map<string, number>();
const ids = new Set(entities.map((e) => e.id));
for (const r of relations) {
if (ids.has(r.source_entity_id)) deg.set(r.source_entity_id, (deg.get(r.source_entity_id) ?? 0) + 1);
if (ids.has(r.target_entity_id)) deg.set(r.target_entity_id, (deg.get(r.target_entity_id) ?? 0) + 1);
}
return deg;
}
// --- Graph data types for react-force-graph ---
interface GraphNode {
id: string;
name: string;
entityType: string;
color: string;
neighbors: Set<string>;
linkIds: Set<string>;
degree: number;
// Force-graph adds these at runtime
x?: number;
y?: number;
}
interface GraphLink {
id: string;
source: string;
target: string;
label: string;
}
interface KGGraphViewProps {
entities: KGEntity[];
relations: KGRelation[];
@@ -85,48 +49,18 @@ export function KGGraphView({ entities: allEntities, relations: allRelations, on
// --- Limit entities by degree centrality ---
const totalCount = allEntities.length;
const isLimited = totalCount > nodeLimit;
const entities = useMemo(() => {
if (totalCount <= nodeLimit) return allEntities;
const deg = computeDegreeMap(allEntities, allRelations);
return [...allEntities].sort((a, b) => (deg.get(b.id) ?? 0) - (deg.get(a.id) ?? 0)).slice(0, nodeLimit);
}, [allEntities, allRelations, totalCount, nodeLimit]);
const entities = useMemo(
() => limitEntitiesByDegree(allEntities, allRelations, nodeLimit),
[allEntities, allRelations, nodeLimit],
);
const entityMap = useMemo(() => new Map(entities.map((e) => [e.id, e])), [entities]);
// --- Build graph data for react-force-graph ---
const graphData = useMemo(() => {
const entityIds = new Set(entities.map((e) => e.id));
const degreeMap = computeDegreeMap(entities, allRelations);
const nodes: GraphNode[] = entities.map((e) => ({
id: e.id,
name: e.name,
entityType: e.entity_type,
color: TYPE_COLORS[e.entity_type] ?? DEFAULT_COLOR,
neighbors: new Set<string>(),
linkIds: new Set<string>(),
degree: degreeMap.get(e.id) ?? 0,
}));
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
const links: GraphLink[] = allRelations
.filter((r) => entityIds.has(r.source_entity_id) && entityIds.has(r.target_entity_id))
.map((r) => {
nodeMap.get(r.source_entity_id)?.neighbors.add(r.target_entity_id);
nodeMap.get(r.target_entity_id)?.neighbors.add(r.source_entity_id);
nodeMap.get(r.source_entity_id)?.linkIds.add(r.id);
nodeMap.get(r.target_entity_id)?.linkIds.add(r.id);
return {
id: r.id,
source: r.source_entity_id,
target: r.target_entity_id,
label: r.relation_type.replace(/_/g, " "),
};
});
return { nodes, links };
}, [entities, allRelations]);
const graphData = useMemo(
() => buildGraphData(entities, allRelations),
[entities, allRelations],
);
// --- Highlight sets derived from selected node ---
const { highlightNodes, highlightLinks } = useMemo(() => {
@@ -8,7 +8,8 @@ import { useWsEvent } from "@/hooks/use-ws-event";
import { Events } from "@/api/protocol";
import { formatDate, formatDuration, formatTokens, computeDurationMs } from "@/lib/format";
import { useUiStore } from "@/stores/use-ui-store";
import { SpanTreeNode, StatusBadge, buildSpanTree } from "./trace-span-tree-node";
import { SpanTreeNode, StatusBadge } from "./trace-span-tree-node";
import { buildSpanTree } from "@/adapters/trace.adapter";
import { TracePreviewBlock } from "./trace-preview-block";
import type { TraceData, SpanData } from "./hooks/use-traces";
import type { AgentEventPayload } from "@/types/chat";
@@ -6,27 +6,11 @@ import { formatDate, formatDuration, formatTokens, computeDurationMs } from "@/l
import { useUiStore } from "@/stores/use-ui-store";
import { TracePreviewBlock } from "./trace-preview-block";
import type { SpanData } from "./hooks/use-traces";
import { buildSpanTree } from "@/adapters/trace.adapter";
import type { SpanNode } from "@/adapters/trace.adapter";
export interface SpanNode {
span: SpanData;
children: SpanNode[];
}
/** Build a parent→children tree from flat span list */
export function buildSpanTree(spans: SpanData[]): SpanNode[] {
const map = new Map<string, SpanNode>();
const roots: SpanNode[] = [];
for (const span of spans) map.set(span.id, { span, children: [] });
for (const span of spans) {
const node = map.get(span.id)!;
if (span.parent_span_id && map.has(span.parent_span_id)) {
map.get(span.parent_span_id)!.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
export { buildSpanTree };
export type { SpanNode };
export function StatusBadge({ status }: { status: string }) {
const isOk = status === "ok" || status === "success" || status === "completed";