mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-03 05:20:01 +00:00
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:
1 parent
7e62623123
commit
1313f02cf4
7 files changed
+135
-98
No files matched your search
File renamed without changes.
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user