Feat: Search for knowledge-base-level graph nodes. (#17444)

This commit is contained in:
balibabu
2026-07-27 21:03:30 +08:00
committed by GitHub
parent 1ae1dd1edb
commit 1cce9b64f1
98 changed files with 3614 additions and 402 deletions

View File

@@ -7,8 +7,13 @@ import {
getNodeRadius as defaultGetNodeRadius,
MinNodeRadius,
} from './node-style';
import { type ArtifactForceGraphProps, type ArtifactGraphNode } from './types';
import {
type ArtifactForceGraphProps,
type ArtifactGraphLink,
type ArtifactGraphNode,
} from './types';
import { useArtifactGraphData } from './use-artifact-graph-data';
import { useCenterGravity } from './use-center-gravity';
import { useContainerDimensions } from './use-container-dimensions';
import { useGraphHighlight } from './use-graph-highlight';
import { defaultMapNodeToValue } from './utils';
@@ -35,6 +40,7 @@ function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
);
const hasFittedRef = useRef(false);
const dimensions = useContainerDimensions(containerRef, show);
const hasDimensions = dimensions.width > 0 && dimensions.height > 0;
const graphData = useArtifactGraphData({
data,
@@ -43,16 +49,6 @@ function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
getNodeRadius,
});
const getBaseLinkColor = useCallback(() => {
if (typeof window === 'undefined' || !containerRef.current) {
return '#b2b5b7';
}
return window
.getComputedStyle(containerRef.current)
.getPropertyValue('--text-disabled')
.trim();
}, []);
// Resolve the controlled id back to a node object reference (highlighting relies on the node's __neighbors/__links)
const pinnedNode = useMemo(
() =>
@@ -69,12 +65,14 @@ function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
getLinkColor,
getLinkWidth,
paintNode,
} = useGraphHighlight(getBaseLinkColor, pinnedNode);
} = useGraphHighlight(containerRef, pinnedNode);
useEffect(() => {
hasFittedRef.current = false;
}, [graphData]);
useCenterGravity(fgRef, hasDimensions);
const handleEngineStop = useCallback(() => {
if (!hasFittedRef.current && fgRef.current) {
fgRef.current.zoomToFit(400);
@@ -94,12 +92,24 @@ function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
[],
);
// Hover tooltip shows the entity description; empty string hides it
const getNodeLabel = useCallback(
(node: ArtifactGraphNode) => node.description ?? '',
[],
);
// Empty label hides the tooltip, so relations without a type show nothing
const getLinkLabel = useCallback(
(link: ArtifactGraphLink) => link.type ?? '',
[],
);
return (
<div
ref={containerRef}
className={cn('flex-1 min-h-0 h-full', !show && 'hidden')}
>
{dimensions.width > 0 && dimensions.height > 0 && (
{hasDimensions && (
<ForceGraph2D
ref={fgRef}
width={dimensions.width}
@@ -109,7 +119,7 @@ function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
nodeColor={nodeColor}
nodeVal={nodeVal}
cooldownTicks={100}
nodeLabel={''}
nodeLabel={getNodeLabel}
autoPauseRedraw={false}
onEngineStop={handleEngineStop}
onNodeClick={handleNodeClick}
@@ -118,6 +128,7 @@ function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
nodeCanvasObjectMode={nodeCanvasObjectMode}
linkColor={getLinkColor}
linkWidth={getLinkWidth}
linkLabel={getLinkLabel}
/>
)}
</div>

View File

@@ -20,6 +20,6 @@ export const renderNodeLabel: NonNullable<
ctx.fillStyle = `rgb(${textSecondary})`;
if (typeof graphNode.x === 'number' && typeof graphNode.y === 'number') {
ctx.fillText(label, graphNode.x, graphNode.y + radius - 2);
ctx.fillText(label, graphNode.x, graphNode.y + radius - 9);
}
};

View File

@@ -14,7 +14,7 @@ export type ArtifactGraphNode = NodeObject<IArtifactGraphEntity> & {
export type ArtifactGraphLink = LinkObject<
ArtifactGraphNode,
{ source: string; target: string }
{ source: string; target: string; type?: string }
>;
export interface ArtifactForceGraphProps<TNodeValue = IArtifactGraphEntity> {

View File

@@ -51,6 +51,7 @@ export const useArtifactGraphData = ({
(relation) => ({
source: relation.from,
target: relation.to,
type: relation.type,
}),
);

View File

@@ -0,0 +1,28 @@
import { forceX, forceY } from 'd3-force';
import { useEffect, type RefObject } from 'react';
import { type ForceGraphMethods } from 'react-force-graph-2d';
import { type ArtifactGraphNode } from './types';
// Weak gravity pulling every node toward the center so disconnected
// components and isolated nodes are not flung far away by charge repulsion.
const CenterGravityStrength = 0.08;
// Register weak center gravity once the graph is mounted; the forces live
// on the d3 simulation and persist across graphData changes.
export function useCenterGravity(
fgRef: RefObject<ForceGraphMethods<ArtifactGraphNode> | undefined>,
hasDimensions: boolean,
) {
useEffect(() => {
const fg = fgRef.current;
if (!hasDimensions || !fg) return;
fg.d3Force(
'x',
forceX<ArtifactGraphNode>(0).strength(CenterGravityStrength),
);
fg.d3Force(
'y',
forceY<ArtifactGraphNode>(0).strength(CenterGravityStrength),
);
}, [fgRef, hasDimensions]);
}

View File

@@ -7,14 +7,14 @@ import {
HighlightLinkWidth,
} from './node-style';
import { type ArtifactGraphLink, type ArtifactGraphNode } from './types';
import { withAlpha } from './utils';
import { getBaseLinkColor, withAlpha } from './utils';
type PaintNodeFn = NonNullable<
ComponentProps<typeof ForceGraph2D>['nodeCanvasObject']
>;
export const useGraphHighlight = (
getBaseLinkColor: () => string,
containerRef: React.RefObject<HTMLElement>,
pinnedNode?: ArtifactGraphNode | null,
) => {
const [hoverNode, setHoverNode] = useState<ArtifactGraphNode | null>(null);
@@ -50,12 +50,12 @@ export const useGraphHighlight = (
const getLinkColor = useCallback(
(link: ArtifactGraphLink) => {
const baseColor = getBaseLinkColor();
const baseColor = getBaseLinkColor(containerRef.current);
return activeNode && !highlightLinks.has(link)
? withAlpha(baseColor, DimmedAlpha)
: baseColor;
},
[getBaseLinkColor, activeNode, highlightLinks],
[containerRef, activeNode, highlightLinks],
);
const getLinkWidth = useCallback(

View File

@@ -4,6 +4,16 @@ export const defaultMapNodeToValue = <TNode extends IArtifactGraphEntity>(
node: TNode,
): TNode => node;
export const getBaseLinkColor = (element?: HTMLElement | null): string => {
if (typeof window === 'undefined' || !element) {
return '#b2b5b7';
}
return window
.getComputedStyle(element)
.getPropertyValue('--border-default')
.trim();
};
export const withAlpha = (color: string, alpha: number): string => {
if (color.length === 7 && color.startsWith('#')) {
return (

View File

@@ -24,7 +24,7 @@ import classNames from 'classnames';
import DOMPurify from 'dompurify';
import 'katex/dist/katex.min.css';
import { omit } from 'lodash';
import { pipe } from 'lodash/fp';
import pipe from 'lodash/fp/pipe';
import { Info } from 'lucide-react';
import { useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -29,7 +29,7 @@ import {
} from '@/utils/chat';
import classNames from 'classnames';
import { omit } from 'lodash';
import { pipe } from 'lodash/fp';
import pipe from 'lodash/fp/pipe';
import reactStringReplace from 'react-string-replace';
import { LoadingDots } from '../loading-dots';
import { Button } from '../ui/button';

View File

@@ -33,7 +33,7 @@ import { useLoadingPause } from '@/hooks/use-loading-pause';
import { cn } from '@/lib/utils';
import classNames from 'classnames';
import { omit } from 'lodash';
import { pipe } from 'lodash/fp';
import pipe from 'lodash/fp/pipe';
import reactStringReplace from 'react-string-replace';
import { LoadingDots } from '../loading-dots';
import { Button } from '../ui/button';

View File

@@ -2,6 +2,7 @@
import { CheckIcon, ChevronDownIcon, XIcon } from 'lucide-react';
import {
KeyboardEvent,
MouseEventHandler,
ReactNode,
forwardRef,
@@ -54,6 +55,8 @@ export type SelectWithSearchFlagProps = {
placeholder?: string;
emptyData?: string;
allowCustomValue?: boolean;
onNoMatchEnter?(searchValue: string): void;
disableAutoSelectOnEnter?: boolean;
testId?: string;
optionTestIdPrefix?: string;
};
@@ -81,6 +84,36 @@ function findLabelWithOptions(
.filter(Boolean)[0]?.label;
}
function hasMatchingOptions(
options: SelectWithSearchFlagOptionType[],
searchValue: string,
) {
const search = searchValue.trim();
if (!search) {
return true;
}
return options.some((group) => {
if (group.options) {
return group.options.some(
(option) =>
filterFn(
option.value ?? '',
search,
typeof option.label === 'string' ? [option.label] : [],
) === 1,
);
}
return (
filterFn(
group.value ?? '',
search,
group.keywords ??
(typeof group.label === 'string' ? [group.label] : []),
) === 1
);
});
}
export const SelectWithSearch = forwardRef<
React.ElementRef<typeof Button>,
SelectWithSearchFlagProps
@@ -96,6 +129,8 @@ export const SelectWithSearch = forwardRef<
placeholder = t('common.selectPlaceholder'),
emptyData = t('common.noDataFound'),
allowCustomValue = false,
onNoMatchEnter,
disableAutoSelectOnEnter = false,
testId,
optionTestIdPrefix,
},
@@ -176,6 +211,23 @@ export const SelectWithSearch = forwardRef<
[onChange],
);
const handleInputKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
const keywords = searchValue.trim();
if (e.key === 'Enter' && keywords) {
if (disableAutoSelectOnEnter) {
e.preventDefault();
onNoMatchEnter?.(keywords);
setSearchValue('');
setOpen(false);
} else if (!hasMatchingOptions(options, keywords)) {
onNoMatchEnter?.(keywords);
}
}
},
[searchValue, options, onNoMatchEnter, disableAutoSelectOnEnter],
);
useEffect(() => {
setValue(val);
}, [val]);
@@ -235,6 +287,7 @@ export const SelectWithSearch = forwardRef<
className=" placeholder:text-text-disabled"
value={searchValue}
onValueChange={setSearchValue}
onKeyDown={handleInputKeyDown}
/>
)}
<CommandList className="mt-2 outline-none">

View File

@@ -0,0 +1,341 @@
import { type TreeDataItem } from '@/components/ui/tree-view';
import {
type IArtifactGraph,
type IArtifactGraphEntity,
} from '@/interfaces/database/dataset';
import {
type IStructureGraphEntity,
type IStructureGraphRelation,
type IStructureGraphTemplate,
} from '@/interfaces/database/document-structure';
import { type TreeData } from '@antv/g6/lib/types';
declare module '@/components/ui/tree-view' {
interface TreeDataItem {
source_chunk_ids?: string[];
}
}
export function getEntityDisplayName(entity: IStructureGraphEntity) {
return entity.name ?? entity.id ?? '';
}
function normalizeEntity(entity: IStructureGraphEntity) {
const id = entity.id ?? entity.name ?? '';
const name = getEntityDisplayName(entity);
return { ...entity, id, name };
}
function getEntityDescription(entity: IStructureGraphEntity): string {
return entity.description ?? entity.discription ?? '';
}
function buildTreeDataItems(
entities: IStructureGraphEntity[],
relations: IStructureGraphRelation[],
relationTypes: string[],
showEntityType = false,
): TreeDataItem[] {
const normalized = entities
.map(normalizeEntity)
.filter((entity) => entity.id);
const map = new Map<string, TreeDataItem>(
normalized.map((entity) => [
entity.id,
{
id: entity.id,
name: entity.name,
entityType: showEntityType ? entity.type : undefined,
source_chunk_ids: entity.source_chunk_ids,
},
]),
);
const childIds = new Set<string>();
for (const relation of relations) {
if (!relationTypes.includes(relation.type ?? '')) continue;
// Self-referencing relation (same entity as its own child) creates
// an infinite recursion in the tree renderer. This is a backend
// data integrity issue (duplicate entity names) but we defend
// against it in the frontend so the UI never hangs.
if (relation.from === relation.to) continue;
const parent = map.get(relation.from);
const child = map.get(relation.to);
if (!parent || !child) continue;
childIds.add(child.id);
parent.children = parent.children ?? [];
parent.children.push(child);
}
return normalized
.filter((entity) => !childIds.has(entity.id))
.map((entity) => map.get(entity.id))
.filter((item): item is TreeDataItem => item !== undefined);
}
function buildUniqueTreeDataItems(
entities: IStructureGraphEntity[],
relations: IStructureGraphRelation[],
relationTypes: string[],
): TreeDataItem[] {
const normalized = [
...new Map(
entities
.map(normalizeEntity)
.filter((entity) => entity.id)
.map((entity) => [entity.id, entity]),
).values(),
];
const map = new Map<string, TreeDataItem>(
normalized.map((entity) => [
entity.id,
{
id: entity.id,
name: entity.name,
entityType: entity.type,
source_chunk_ids: entity.source_chunk_ids,
},
]),
);
const childIds = new Set<string>();
const parentMap = new Map<string, string>();
for (const relation of relations) {
if (!relationTypes.includes(relation.type ?? '')) continue;
const parent = map.get(relation.from);
const child = map.get(relation.to);
if (!parent || !child) continue;
if (childIds.has(child.id)) continue;
// Avoid cycles: do not attach a node under one of its descendants.
let cursor = relation.from;
let wouldCycle = false;
while (parentMap.has(cursor)) {
const ancestor = parentMap.get(cursor)!;
if (ancestor === child.id) {
wouldCycle = true;
break;
}
cursor = ancestor;
}
if (wouldCycle) continue;
childIds.add(child.id);
parentMap.set(child.id, relation.from);
parent.children = parent.children ?? [];
parent.children.push(child);
}
return normalized
.filter((entity) => !childIds.has(entity.id))
.map((entity) => map.get(entity.id))
.filter((item): item is TreeDataItem => item !== undefined);
}
export function adaptPageIndexToTreeData(
template: IStructureGraphTemplate,
): TreeDataItem[] {
return buildTreeDataItems(
template.entities,
template.relations,
['include'],
true,
);
}
export function adaptTreeToTreeData(
template: IStructureGraphTemplate,
): TreeDataItem[] {
return buildTreeDataItems(template.entities, template.relations, ['child']);
}
function filterTreeDataItems(
items: TreeDataItem[],
keyword: string,
): TreeDataItem[] {
const lowerKeyword = keyword.toLowerCase();
return items.reduce<TreeDataItem[]>((acc, item) => {
const children = item.children
? filterTreeDataItems(item.children, keyword)
: [];
const matches = item.name.toLowerCase().includes(lowerKeyword);
if (matches || children.length > 0) {
acc.push({
...item,
children: children.length > 0 ? children : item.children,
});
}
return acc;
}, []);
}
export function filterTreeDataByKeyword(
data: TreeDataItem[],
keyword: string,
): TreeDataItem[] {
if (!keyword.trim()) return data;
return filterTreeDataItems(data, keyword);
}
export function adaptKnowledgeGraphToForceGraph(
template: IStructureGraphTemplate,
): IArtifactGraph {
const entities: IArtifactGraphEntity[] = template.entities.map((entity) => {
const normalized = normalizeEntity(entity);
return {
slug: normalized.id,
name: normalized.name,
aliases: normalized.aliases ?? [],
description: getEntityDescription(normalized),
type: normalized.type ?? '',
weight: normalized.mention_count ?? 0,
source_chunk_ids: normalized.source_chunk_ids,
};
});
const entityNames = new Set(entities.map((entity) => entity.slug));
return {
entities,
relations: template.relations
.filter(
(relation) =>
// Only keep relations whose source and target entities both exist in the graph.
entityNames.has(relation.from) && entityNames.has(relation.to),
)
.map((relation) => ({
from: relation.from,
to: relation.to,
type: relation.type ?? '',
})),
};
}
function treeDataItemToG6TreeData(item: TreeDataItem): TreeData {
const node: TreeData = {
id: item.id,
data: {
name: item.name,
source_chunk_ids: item.source_chunk_ids,
},
};
if (item.children && item.children.length > 0) {
node.children = item.children.map(treeDataItemToG6TreeData);
}
return node;
}
export interface TimelineX6NodeData {
id: string;
shape: 'rect' | 'circle';
width: number;
height: number;
label: string;
data?: IStructureGraphEntity;
attrs?: Record<string, unknown>;
}
export interface TimelineX6EdgeData {
id: string;
shape: 'edge';
source: string;
target: string;
attrs?: Record<string, unknown>;
}
export function adaptTimelineToX6Data(template: IStructureGraphTemplate): {
nodes: TimelineX6NodeData[];
edges: TimelineX6EdgeData[];
} {
const normalized = template.entities
.map(normalizeEntity)
.filter((entity) => entity.id);
const entityIds = new Set(normalized.map((entity) => entity.id));
const nodes = normalized.map((entity) => {
const isTimestamp = entity.type === 'timestamp';
return {
id: entity.id,
shape: isTimestamp ? ('circle' as const) : ('rect' as const),
width: isTimestamp ? 40 : 200,
height: isTimestamp ? 40 : 80,
label: entity.name,
data: entity,
attrs: {
body: {
fill: isTimestamp ? 'rgb(var(--accent-primary))' : 'transparent',
stroke: 'rgb(var(--accent-primary))',
rx: isTimestamp ? 48 : 8,
ry: isTimestamp ? 48 : 8,
},
label: {
fill: isTimestamp
? 'rgb(var(--accent-primary))'
: 'rgb(var(--text-primary))',
fontSize: 12,
...(isTimestamp && {
refX: '50%',
refY: 0,
refY2: -8,
textAnchor: 'middle',
textVerticalAnchor: 'bottom',
}),
textWrap: {
width: isTimestamp ? 80 : 180,
height: isTimestamp ? 80 : 64,
ellipsis: true,
},
},
},
};
});
const edges = template.relations
.filter(
(relation) => entityIds.has(relation.from) && entityIds.has(relation.to),
)
.map((relation, index) => ({
id: `timeline-edge-${index}`,
shape: 'edge' as const,
source: relation.from,
target: relation.to,
attrs: {
line: {
stroke: '#8c8c8c',
strokeWidth: 1,
targetMarker: 'classic',
},
},
}));
return { nodes, edges };
}
export function adaptMindMapToIndentedTree(
template: IStructureGraphTemplate,
): TreeData {
const roots = buildUniqueTreeDataItems(
template.entities,
template.relations,
['has_branch', 'has_sub_branch'],
);
const g6Roots = roots.map(treeDataItemToG6TreeData);
if (g6Roots.length === 1) {
return g6Roots[0]!;
}
return {
id: 'mindmap-root',
children: g6Roots,
};
}

View File

@@ -0,0 +1,161 @@
import { SkeletonCard } from '@/components/skeleton-card';
import { cn } from '@/lib/utils';
import { Graph, IElementEvent, NodeEvent, treeToGraphData } from '@antv/g6';
import { memo, useEffect, useRef, useState } from 'react';
import { adaptMindMapToIndentedTree } from '../adapters';
import { type MindMapG6GraphProps, type MindMapNodeValue } from './types';
interface MindMapNodeData {
name?: string;
source_chunk_ids?: string[];
}
function MindMapG6Graph({
template,
show = true,
onNodeClick,
}: MindMapG6GraphProps) {
const containerRef = useRef<HTMLDivElement>(null);
const graphRef = useRef<Graph | null>(null);
const [loading, setLoading] = useState(true);
const [graphData, setGraphData] = useState<ReturnType<
typeof treeToGraphData
> | null>(null);
// Defer heavy data transformation to avoid blocking the render phase.
useEffect(() => {
setLoading(true);
setGraphData(null);
const rafId = requestAnimationFrame(() => {
setGraphData(treeToGraphData(adaptMindMapToIndentedTree(template)));
});
return () => {
cancelAnimationFrame(rafId);
};
}, [template]);
useEffect(() => {
const container = containerRef.current;
if (!container || !graphData) return;
const styles = window.getComputedStyle(container);
const textPrimary = styles.getPropertyValue('--text-primary').trim();
const textSecondary = styles.getPropertyValue('--text-secondary').trim();
const accentPrimary = styles.getPropertyValue('--accent-primary').trim();
let graph: Graph | null = null;
let observer: ResizeObserver | null = null;
const createGraph = () => {
if (graph) return;
const { width, height } = container.getBoundingClientRect();
if (width === 0 || height === 0) return;
graph = new Graph({
container,
width,
height,
autoFit: 'view',
animation: false,
data: graphData,
node: {
style: {
labelText: (datum) =>
(datum.data as MindMapNodeData | undefined)?.name ?? datum.id,
labelFill: textPrimary ? `rgb(${textPrimary})` : '#262626',
fill: accentPrimary ? `rgb(${accentPrimary})` : '#00beb4',
labelBackground: true,
labelBackgroundFill: 'transparent',
labelPlacement: 'top',
},
},
edge: {
type: 'cubic-horizontal',
style: {
stroke: textSecondary ? `rgb(${textSecondary})` : '#75787a',
},
},
layout: {
type: 'mindmap',
direction: 'H',
preLayout: false,
getHeight: () => 32,
getWidth: () => 120,
getVGap: () => 10,
getHGap: () => 80,
},
behaviors: ['collapse-expand', 'drag-canvas', 'zoom-canvas'],
});
const handleNodeClick = (evt: IElementEvent) => {
const nodeId = evt.target.id as string;
const nodeData = graph!.getNodeData(nodeId);
const data = nodeData.data as MindMapNodeData | undefined;
if (data?.source_chunk_ids?.length) {
const payload: MindMapNodeValue = {
id: nodeId,
name: data.name || nodeId,
source_chunk_ids: data.source_chunk_ids,
};
onNodeClick?.(payload);
}
};
graph.on<IElementEvent>(NodeEvent.CLICK, handleNodeClick);
graphRef.current = graph;
void graph.render().then(() => {
setLoading(false);
});
};
// Defer graph creation so the browser paints the skeleton before the
// heavy G6 layout + render starts.
const rafId = requestAnimationFrame(() => {
observer = new ResizeObserver(() => {
if (!graph) {
createGraph();
return;
}
const { width, height } = container.getBoundingClientRect();
if (width > 0 && height > 0) {
graph.resize(width, height);
}
});
observer.observe(container);
createGraph();
});
return () => {
cancelAnimationFrame(rafId);
observer?.disconnect();
graph?.destroy();
graphRef.current = null;
};
}, [graphData, onNodeClick]);
return (
<div className="relative flex-1 min-h-0 h-full">
{loading && (
<div className="absolute inset-0 flex items-center justify-center">
<SkeletonCard className="w-80" />
</div>
)}
<div
ref={containerRef}
className={cn('h-full', !show && 'hidden', loading && 'invisible')}
/>
</div>
);
}
const MemoizedMindMapG6Graph = memo(MindMapG6Graph) as typeof MindMapG6Graph;
export { MemoizedMindMapG6Graph as MindMapG6Graph };
export default MemoizedMindMapG6Graph;

View File

@@ -0,0 +1,13 @@
import { type IStructureGraphTemplate } from '@/interfaces/database/document-structure';
export interface MindMapNodeValue {
id: string;
name: string;
source_chunk_ids?: string[];
}
export interface MindMapG6GraphProps {
template: IStructureGraphTemplate;
show?: boolean;
onNodeClick?: (node: MindMapNodeValue) => void;
}

View File

@@ -0,0 +1,226 @@
import ArtifactForceGraph from '@/components/artifact-force-graph';
import { TreeView, type TreeDataItem } from '@/components/ui/tree-view';
import { CompilationTemplateKind } from '@/constants/compilation';
import {
type IArtifactGraph,
type IArtifactGraphEntity,
} from '@/interfaces/database/dataset';
import {
type IStructureGraphTemplate,
type StructureTemplateKind,
} from '@/interfaces/database/document-structure';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
adaptKnowledgeGraphToForceGraph,
adaptPageIndexToTreeData,
adaptTimelineToX6Data,
adaptTreeToTreeData,
filterTreeDataByKeyword,
} from './adapters';
import MindMapG6Graph from './mindmap-g6-graph';
import TimelineX6Graph from './timeline-x6-graph';
export interface ClickableNode {
id: string;
name?: string;
source_chunk_ids?: string[];
}
const EmptyForceGraphData: IArtifactGraph = { entities: [], relations: [] };
interface RepresentationRendererProps {
template?: IStructureGraphTemplate;
searchKeyword?: string;
onNodeClick?: (node: ClickableNode) => void;
highlightNodeId?: string | null;
}
function UnsupportedPlaceholder({ kind }: { kind: StructureTemplateKind }) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-center h-full text-text-secondary">
{t('chunk.representationUnsupported', {
kind,
defaultValue: 'This representation type is not supported yet.',
})}
</div>
);
}
export function RepresentationRenderer({
template,
searchKeyword = '',
onNodeClick,
highlightNodeId,
}: RepresentationRendererProps) {
const handleTreeItemClick = useCallback(
(item: TreeDataItem | undefined) => {
if (item?.source_chunk_ids?.length) {
onNodeClick?.({
id: item.id,
name: item.name,
source_chunk_ids: item.source_chunk_ids,
});
}
},
[onNodeClick],
);
const handleArtifactNodeClick = useCallback(
(node: IArtifactGraphEntity) => {
if (node.source_chunk_ids?.length) {
onNodeClick?.({
id: node.slug,
name: node.name,
source_chunk_ids: node.source_chunk_ids,
});
}
},
[onNodeClick],
);
const handleTimelineNodeClick = useCallback(
(node: { id: string; name: string; source_chunk_ids?: string[] }) => {
if (node.source_chunk_ids?.length) {
onNodeClick?.(node);
}
},
[onNodeClick],
);
const handleMindMapNodeClick = useCallback(
(node: ClickableNode) => {
if (node.source_chunk_ids?.length) {
onNodeClick?.(node);
}
},
[onNodeClick],
);
const getArtifactNodeName = useCallback(
(node: IArtifactGraphEntity) => node.name,
[],
);
const filteredTreeData = useMemo<TreeDataItem[]>(() => {
if (!template) return [];
if (template.kind === CompilationTemplateKind.PageIndex) {
const data = adaptPageIndexToTreeData(template);
return searchKeyword.trim()
? filterTreeDataByKeyword(data, searchKeyword)
: data;
}
if (template.kind === CompilationTemplateKind.Tree) {
const data = adaptTreeToTreeData(template);
return searchKeyword.trim()
? filterTreeDataByKeyword(data, searchKeyword)
: data;
}
return [];
}, [template, searchKeyword]);
// Keep a stable reference across re-renders so the memoized ArtifactForceGraph
// does not restart its force simulation when only highlightNodeId changes
const forceGraphData = useMemo<IArtifactGraph>(
() =>
template
? adaptKnowledgeGraphToForceGraph(template)
: EmptyForceGraphData,
[template],
);
if (!template) {
return null;
}
switch (template.kind) {
case CompilationTemplateKind.PageIndex:
return (
<div className="mt-6 overflow-auto scrollbar-auto">
<TreeView
data={filteredTreeData}
expandAll
onSelectChange={handleTreeItemClick}
/>
</div>
);
case CompilationTemplateKind.Tree:
return (
<div className="mt-6 overflow-auto scrollbar-auto">
<TreeView
data={filteredTreeData}
expandAll
onSelectChange={handleTreeItemClick}
/>
</div>
);
case CompilationTemplateKind.KnowledgeGraph:
return (
<div className="mt-6 flex-1 min-h-0">
<ArtifactForceGraph
data={forceGraphData}
show
getNodeId={getArtifactNodeName}
onNodeClick={handleArtifactNodeClick}
highlightNodeId={highlightNodeId}
/>
</div>
);
case CompilationTemplateKind.Timeline:
return (
<div className="mt-6 flex-1 min-h-0">
<TimelineX6Graph
data={adaptTimelineToX6Data(template)}
show
onNodeClick={handleTimelineNodeClick}
/>
</div>
);
case CompilationTemplateKind.MindMap:
return (
<div className="mt-6 flex-1 min-h-0">
<MindMapG6Graph
template={template}
show
onNodeClick={handleMindMapNodeClick}
/>
</div>
);
case CompilationTemplateKind.SessionGraph:
return (
<div className="mt-6 flex-1 min-h-0">
<ArtifactForceGraph
data={forceGraphData}
show
getNodeId={getArtifactNodeName}
onNodeClick={handleArtifactNodeClick}
/>
</div>
);
case CompilationTemplateKind.SessionEssence:
return (
<div className="mt-6 flex-1 min-h-0">
<MindMapG6Graph
template={template}
show
onNodeClick={handleMindMapNodeClick}
/>
</div>
);
case CompilationTemplateKind.Empty:
return (
<div className="mt-6 flex-1 min-h-0">
<ArtifactForceGraph
data={forceGraphData}
show
getNodeId={getArtifactNodeName}
onNodeClick={handleArtifactNodeClick}
/>
</div>
);
default:
return <UnsupportedPlaceholder kind={template.kind} />;
}
}

View File

@@ -0,0 +1,89 @@
import { DagreLayout } from '@antv/layout';
import { Graph, type EdgeMetadata, type NodeMetadata } from '@antv/x6';
import { useEffect, useRef } from 'react';
import { type TimelineX6NodeData } from '../../adapters';
import { type TimelineNodeValue, type TimelineX6GraphProps } from '../types';
export function useX6Graph(
containerRef: React.RefObject<HTMLDivElement | null>,
data: TimelineX6GraphProps['data'],
onNodeClick?: (node: TimelineNodeValue) => void,
) {
const graphRef = useRef<Graph | null>(null);
// Initialize the X6 graph instance once.
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const graph = new Graph({
container,
autoResize: true,
background: { color: 'transparent' },
grid: false,
panning: { enabled: true },
mousewheel: { enabled: true },
scaling: { min: 0.1, max: 3 },
});
graph.on('node:click', ({ node }) => {
const entity = node.getData<Record<string, unknown> | undefined>();
const chunkIds = entity?.source_chunk_ids as string[] | undefined;
if (chunkIds?.length) {
onNodeClick?.({
id: node.id,
name: (entity?.name as string) || node.id,
source_chunk_ids: chunkIds,
});
}
});
graphRef.current = graph;
return () => {
graph.dispose();
graphRef.current = null;
};
}, [containerRef, onNodeClick]);
// Re-layout and render whenever the graph data changes.
useEffect(() => {
const graph = graphRef.current;
if (!graph || !data.nodes.length) return;
const dagreLayout = new DagreLayout({
type: 'dagre',
rankdir: 'LR',
ranksep: 240,
nodesep: 200,
edgeMinLen: 2,
controlPoints: false,
});
const layoutData = {
nodes: data.nodes.map((node) => ({ ...node })) as NodeMetadata[],
edges: data.edges.map((edge) => ({ ...edge })) as EdgeMetadata[],
};
dagreLayout.execute(layoutData).then(() => {
const positionedNodes = new Map<string, NodeMetadata>();
dagreLayout.forEachNode((node) => {
const original = node._original as TimelineX6NodeData;
positionedNodes.set(String(node.id), {
...(node._original as NodeMetadata),
x: (node.x ?? 0) - original.width / 2,
y: (node.y ?? 0) - original.height / 2,
});
});
const nodes = layoutData.nodes
.map((node) => positionedNodes.get(node.id as string))
.filter(Boolean) as NodeMetadata[];
graph.fromJSON({ nodes, edges: layoutData.edges });
graph.zoomToFit({ padding: 20 });
});
}, [data]);
}

View File

@@ -0,0 +1,24 @@
import { cn } from '@/lib/utils';
import { useRef } from 'react';
import { useX6Graph } from './hooks/use-x6-graph';
import { type TimelineX6GraphProps } from './types';
export function TimelineX6Graph({
data,
show = true,
onNodeClick,
}: TimelineX6GraphProps) {
const containerRef = useRef<HTMLDivElement>(null);
useX6Graph(containerRef, data, onNodeClick);
return (
<div
ref={containerRef}
className={cn('w-full h-full min-h-0', !show && 'hidden')}
/>
);
}
export default TimelineX6Graph;

View File

@@ -0,0 +1,16 @@
import { type TimelineX6EdgeData, type TimelineX6NodeData } from '../adapters';
export interface TimelineNodeValue {
id: string;
name: string;
source_chunk_ids?: string[];
}
export interface TimelineX6GraphProps {
data: {
nodes: TimelineX6NodeData[];
edges: TimelineX6EdgeData[];
};
show?: boolean;
onNodeClick?: (node: TimelineNodeValue) => void;
}