mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-01 13:33:48 +08:00
Feat: Search for knowledge-base-level graph nodes. (#17444)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -51,6 +51,7 @@ export const useArtifactGraphData = ({
|
||||
(relation) => ({
|
||||
source: relation.from,
|
||||
target: relation.to,
|
||||
type: relation.type,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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">
|
||||
|
||||
341
web/src/components/structure-graph/adapters.ts
Normal file
341
web/src/components/structure-graph/adapters.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
161
web/src/components/structure-graph/mindmap-g6-graph/index.tsx
Normal file
161
web/src/components/structure-graph/mindmap-g6-graph/index.tsx
Normal 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;
|
||||
13
web/src/components/structure-graph/mindmap-g6-graph/types.ts
Normal file
13
web/src/components/structure-graph/mindmap-g6-graph/types.ts
Normal 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;
|
||||
}
|
||||
226
web/src/components/structure-graph/representation-renderer.tsx
Normal file
226
web/src/components/structure-graph/representation-renderer.tsx
Normal 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} />;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user