diff --git a/ui/web/src/pages/chat/hooks/chat-message-transformer.ts b/ui/web/src/adapters/chat-message.adapter.ts similarity index 100% rename from ui/web/src/pages/chat/hooks/chat-message-transformer.ts rename to ui/web/src/adapters/chat-message.adapter.ts diff --git a/ui/web/src/adapters/kg-graph.adapter.ts b/ui/web/src/adapters/kg-graph.adapter.ts new file mode 100644 index 00000000..947f3afa --- /dev/null +++ b/ui/web/src/adapters/kg-graph.adapter.ts @@ -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 = { + 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; + linkIds: Set; + 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 { + const deg = new Map(); + 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(), + linkIds: new Set(), + 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); +} diff --git a/ui/web/src/adapters/trace.adapter.ts b/ui/web/src/adapters/trace.adapter.ts new file mode 100644 index 00000000..4dee9257 --- /dev/null +++ b/ui/web/src/adapters/trace.adapter.ts @@ -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(); + 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; +} diff --git a/ui/web/src/pages/chat/hooks/use-chat-messages.ts b/ui/web/src/pages/chat/hooks/use-chat-messages.ts index 90ae4089..c8cab068 100644 --- a/ui/web/src/pages/chat/hooks/use-chat-messages.ts +++ b/ui/web/src/pages/chat/hooks/use-chat-messages.ts @@ -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"; /** diff --git a/ui/web/src/pages/memory/kg-graph-view.tsx b/ui/web/src/pages/memory/kg-graph-view.tsx index ec3d4e07..0026d9ee 100644 --- a/ui/web/src/pages/memory/kg-graph-view.tsx +++ b/ui/web/src/pages/memory/kg-graph-view.tsx @@ -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 = { - 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 { - const deg = new Map(); - 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; - linkIds: Set; - 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(), - linkIds: new Set(), - 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(() => { diff --git a/ui/web/src/pages/traces/trace-detail-dialog.tsx b/ui/web/src/pages/traces/trace-detail-dialog.tsx index 64bba6a0..a7c1d9ab 100644 --- a/ui/web/src/pages/traces/trace-detail-dialog.tsx +++ b/ui/web/src/pages/traces/trace-detail-dialog.tsx @@ -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"; diff --git a/ui/web/src/pages/traces/trace-span-tree-node.tsx b/ui/web/src/pages/traces/trace-span-tree-node.tsx index 4cd99060..d2e5ae93 100644 --- a/ui/web/src/pages/traces/trace-span-tree-node.tsx +++ b/ui/web/src/pages/traces/trace-span-tree-node.tsx @@ -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(); - 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";