Feat: Highlight wiki force graph. (#17160)

This commit is contained in:
balibabu
2026-07-21 16:48:52 +08:00
committed by GitHub
parent 08ed962aed
commit 20b760f266
28 changed files with 908 additions and 98 deletions

View File

@@ -1,8 +1,7 @@
import { type IArtifactGraphEntity } from '@/interfaces/database/dataset';
import { cn } from '@/lib/utils';
import { memo, useCallback, useEffect, useRef } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import ForceGraph2D, { type ForceGraphMethods } from 'react-force-graph-2d';
import { renderNodeLabel } from './node-label';
import {
getNodeColor as defaultGetNodeColor,
getNodeRadius as defaultGetNodeRadius,
@@ -11,8 +10,13 @@ import {
import { type ArtifactForceGraphProps, type ArtifactGraphNode } from './types';
import { useArtifactGraphData } from './use-artifact-graph-data';
import { useContainerDimensions } from './use-container-dimensions';
import { useGraphHighlight } from './use-graph-highlight';
import { defaultMapNodeToValue } from './utils';
const defaultGetNodeId = (node: IArtifactGraphEntity) => node.slug;
const nodeCanvasObjectMode = () => 'after' as const;
function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
data,
show = true,
@@ -20,9 +24,10 @@ function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
mapNodeToValue = defaultMapNodeToValue as (
node: IArtifactGraphEntity,
) => TNodeValue,
getNodeId = (node) => node.slug,
getNodeId = defaultGetNodeId,
getNodeColor = defaultGetNodeColor,
getNodeRadius = defaultGetNodeRadius,
highlightNodeId,
}: ArtifactForceGraphProps<TNodeValue>) {
const containerRef = useRef<HTMLDivElement>(null);
const fgRef = useRef<ForceGraphMethods<ArtifactGraphNode> | undefined>(
@@ -38,6 +43,34 @@ 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(
() =>
highlightNodeId
? ((graphData.nodes.find((node) => node.id === highlightNodeId) ??
null) as ArtifactGraphNode | null)
: null,
[graphData, highlightNodeId],
);
const {
handleNodeHover,
getNodeColor: nodeColor,
getLinkColor,
getLinkWidth,
paintNode,
} = useGraphHighlight(getBaseLinkColor, pinnedNode);
useEffect(() => {
hasFittedRef.current = false;
}, [graphData]);
@@ -56,18 +89,6 @@ function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
[onNodeClick, mapNodeToValue],
);
const getLinkColor = useCallback(() => {
if (typeof window === 'undefined' || !containerRef.current) {
return '#b2b5b7';
}
return window
.getComputedStyle(containerRef.current)
.getPropertyValue('--text-disabled')
.trim();
}, []);
const nodeColor = useCallback((node: ArtifactGraphNode) => node.__color, []);
const nodeVal = useCallback(
(node: ArtifactGraphNode) => node.__radius ?? MinNodeRadius,
[],
@@ -89,11 +110,14 @@ function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
nodeVal={nodeVal}
cooldownTicks={100}
nodeLabel={''}
autoPauseRedraw={false}
onEngineStop={handleEngineStop}
onNodeClick={handleNodeClick}
nodeCanvasObject={renderNodeLabel}
nodeCanvasObjectMode={() => 'after'}
onNodeHover={handleNodeHover}
nodeCanvasObject={paintNode}
nodeCanvasObjectMode={nodeCanvasObjectMode}
linkColor={getLinkColor}
linkWidth={getLinkWidth}
/>
)}
</div>

View File

@@ -6,6 +6,11 @@ export const ConceptNodeColor = '#4CACFF';
export const MinNodeRadius = 4;
export const MaxNodeRadius = 14;
export const DefaultLinkWidth = 1;
export const HighlightLinkWidth = 2;
export const DimmedAlpha = 0.2;
export const getNodeColor = (node: IArtifactGraphEntity): string => {
if (node.type === 'entity') return EntityNodeColor;
if (node.type === 'concept') return ConceptNodeColor;

View File

@@ -2,14 +2,21 @@ import {
type IArtifactGraph,
type IArtifactGraphEntity,
} from '@/interfaces/database/dataset';
import { type NodeObject } from 'react-force-graph-2d';
import { type LinkObject, type NodeObject } from 'react-force-graph-2d';
export type ArtifactGraphNode = NodeObject<IArtifactGraphEntity> & {
id: string;
__color: string;
__radius: number;
__neighbors?: ArtifactGraphNode[];
__links?: ArtifactGraphLink[];
};
export type ArtifactGraphLink = LinkObject<
ArtifactGraphNode,
{ source: string; target: string }
>;
export interface ArtifactForceGraphProps<TNodeValue = IArtifactGraphEntity> {
data?: IArtifactGraph;
show?: boolean;
@@ -22,4 +29,6 @@ export interface ArtifactForceGraphProps<TNodeValue = IArtifactGraphEntity> {
minWeight: number,
maxWeight: number,
) => number;
/** Controlled highlighted node id (same id space as getNodeId output, slug by default); real hover takes precedence over this value */
highlightNodeId?: string | null;
}

View File

@@ -9,7 +9,7 @@ import {
getNodeColor as defaultGetNodeColor,
getNodeRadius as defaultGetNodeRadius,
} from './node-style';
import { type ArtifactGraphNode } from './types';
import { type ArtifactGraphLink, type ArtifactGraphNode } from './types';
export interface UseArtifactGraphDataOptions {
data?: IArtifactGraph;
@@ -40,17 +40,31 @@ export const useArtifactGraphData = ({
const minWeight = Math.min(0, ...weights);
const maxWeight = Math.max(0, ...weights);
const nodes = entities.map((entity) => ({
const nodes: ArtifactGraphNode[] = entities.map((entity) => ({
...entity,
id: getNodeId(entity),
__color: getNodeColor(entity),
__radius: getNodeRadius(entity, minWeight, maxWeight),
}));
const links = (data.relations || []).map((relation) => ({
source: relation.from,
target: relation.to,
}));
const links: ArtifactGraphLink[] = (data.relations || []).map(
(relation) => ({
source: relation.from,
target: relation.to,
}),
);
// cross-link node objects for hover highlighting
const nodesById = new Map(nodes.map((node) => [node.id, node]));
links.forEach((link) => {
const a = nodesById.get(link.source);
const b = nodesById.get(link.target);
if (!a || !b) return;
(a.__neighbors ??= []).push(b);
(b.__neighbors ??= []).push(a);
(a.__links ??= []).push(link);
(b.__links ??= []).push(link);
});
return { nodes, links };
}, [data, getNodeColor, getNodeId, getNodeRadius]);

View File

@@ -0,0 +1,89 @@
import { useCallback, useMemo, useState, type ComponentProps } from 'react';
import ForceGraph2D from 'react-force-graph-2d';
import { renderNodeLabel } from './node-label';
import {
DefaultLinkWidth,
DimmedAlpha,
HighlightLinkWidth,
} from './node-style';
import { type ArtifactGraphLink, type ArtifactGraphNode } from './types';
import { withAlpha } from './utils';
type PaintNodeFn = NonNullable<
ComponentProps<typeof ForceGraph2D>['nodeCanvasObject']
>;
export const useGraphHighlight = (
getBaseLinkColor: () => string,
pinnedNode?: ArtifactGraphNode | null,
) => {
const [hoverNode, setHoverNode] = useState<ArtifactGraphNode | null>(null);
// Real hover takes precedence; falls back to the externally controlled pinned node when not hovering
const activeNode = hoverNode ?? pinnedNode ?? null;
const highlightNodes = useMemo(() => {
const nodes = new Set<ArtifactGraphNode>();
if (activeNode) {
nodes.add(activeNode);
activeNode.__neighbors?.forEach((neighbor) => nodes.add(neighbor));
}
return nodes;
}, [activeNode]);
const highlightLinks = useMemo(
() => new Set<ArtifactGraphLink>(activeNode?.__links ?? []),
[activeNode],
);
const handleNodeHover = useCallback((node: ArtifactGraphNode | null) => {
setHoverNode(node ?? null);
}, []);
const getNodeColor = useCallback(
(node: ArtifactGraphNode) =>
activeNode && !highlightNodes.has(node)
? withAlpha(node.__color, DimmedAlpha)
: node.__color,
[activeNode, highlightNodes],
);
const getLinkColor = useCallback(
(link: ArtifactGraphLink) => {
const baseColor = getBaseLinkColor();
return activeNode && !highlightLinks.has(link)
? withAlpha(baseColor, DimmedAlpha)
: baseColor;
},
[getBaseLinkColor, activeNode, highlightLinks],
);
const getLinkWidth = useCallback(
(link: ArtifactGraphLink) =>
highlightLinks.has(link) ? HighlightLinkWidth : DefaultLinkWidth,
[highlightLinks],
);
const paintNode = useCallback<PaintNodeFn>(
(node, ctx, globalScale) => {
const dimmed =
activeNode !== null && !highlightNodes.has(node as ArtifactGraphNode);
if (dimmed) {
ctx.globalAlpha = DimmedAlpha;
}
renderNodeLabel(node, ctx, globalScale);
if (dimmed) {
ctx.globalAlpha = 1;
}
},
[highlightNodes, activeNode],
);
return {
handleNodeHover,
getNodeColor,
getLinkColor,
getLinkWidth,
paintNode,
};
};

View File

@@ -3,3 +3,18 @@ import { type IArtifactGraphEntity } from '@/interfaces/database/dataset';
export const defaultMapNodeToValue = <TNode extends IArtifactGraphEntity>(
node: TNode,
): TNode => node;
export const withAlpha = (color: string, alpha: number): string => {
if (color.length === 7 && color.startsWith('#')) {
return (
color +
Math.round(alpha * 255)
.toString(16)
.padStart(2, '0')
);
}
if (color.startsWith('rgb(')) {
return `rgba(${color.slice(4, -1)}, ${alpha})`;
}
return color;
};

View File

@@ -1,6 +1,5 @@
import { RAGFlowFormItem } from '@/components/ragflow-form';
import { useFetchAllCompilationTemplateGroups } from '@/hooks/use-compilation-template-group-request';
import { useMemo } from 'react';
import { useCompilationTemplateGroupOptions } from '@/hooks/use-compilation-template-group-request';
import { useTranslation } from 'react-i18next';
import { SelectWithSearch } from './originui/select-with-search';
@@ -14,12 +13,7 @@ export function CompilationTemplateFormField({
name = 'parser_config.compilation_template_group_id',
}: CompilationTemplateFormFieldProps) {
const { t } = useTranslation();
const { groups } = useFetchAllCompilationTemplateGroups();
const options = useMemo(
() => groups?.map((group) => ({ label: group.name, value: group.id })),
[groups],
);
const options = useCompilationTemplateGroupOptions();
return (
<RAGFlowFormItem

View File

@@ -258,3 +258,12 @@ export const useFetchAllCompilationTemplateGroups = () => {
return { groups: data ?? [], loading };
};
export const useCompilationTemplateGroupOptions = () => {
const { groups } = useFetchAllCompilationTemplateGroups();
return useMemo(
() => groups.map((group) => ({ label: group.name, value: group.id })),
[groups],
);
};

View File

@@ -0,0 +1,103 @@
import message from '@/components/ui/message';
import { DatasetNavList } from '@/interfaces/database/dataset-nav';
import i18n from '@/locales/config';
import datasetNavService from '@/services/dataset-nav-service';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useKnowledgeBaseId } from './use-knowledge-request';
export const DatasetNavKeys = {
all: (kbId: string) => ['dataset_nav', kbId] as const,
list: (kbId: string) => ['dataset_nav', kbId, 'list'] as const,
children: (kbId: string, name: string) =>
['dataset_nav', kbId, 'children', name] as const,
};
export function useFetchDatasetNav() {
const kbId = useKnowledgeBaseId();
const { data, isFetching: loading } = useQuery<DatasetNavList | null>({
queryKey: DatasetNavKeys.list(kbId),
initialData: null,
enabled: !!kbId,
gcTime: 0,
queryFn: async () => {
const { data } = await datasetNavService.getNav({ datasetId: kbId });
return data?.data ?? null;
},
});
return { data, loading };
}
export function useFetchDatasetNavChildren(parentName: string | null) {
const kbId = useKnowledgeBaseId();
const enabled = !!kbId && !!parentName;
const { data, isFetching: loading } = useQuery<DatasetNavList | null>({
queryKey: DatasetNavKeys.children(kbId, parentName ?? ''),
initialData: null,
enabled,
gcTime: 0,
queryFn: async () => {
const { data } = await datasetNavService.getNavChildren({
datasetId: kbId,
name: parentName!,
});
return data?.data ?? null;
},
});
return { data, loading };
}
export function useDeleteDatasetNav() {
const kbId = useKnowledgeBaseId();
const queryClient = useQueryClient();
const {
data,
isPending: loading,
mutateAsync,
} = useMutation({
mutationFn: async () => {
const { data } = await datasetNavService.deleteNav({ datasetId: kbId });
if (data?.code === 0) {
message.success(i18n.t('message.deleted'));
queryClient.invalidateQueries({
queryKey: DatasetNavKeys.all(kbId),
});
}
return data;
},
});
return { data, loading, deleteNav: mutateAsync };
}
export function useDeleteDatasetNavNode() {
const kbId = useKnowledgeBaseId();
const queryClient = useQueryClient();
const {
data,
isPending: loading,
mutateAsync,
} = useMutation({
mutationFn: async (name: string) => {
const { data } = await datasetNavService.deleteNavNode({
datasetId: kbId,
name,
});
if (data?.code === 0) {
message.success(i18n.t('message.deleted'));
queryClient.invalidateQueries({
queryKey: DatasetNavKeys.all(kbId),
});
}
return data;
},
});
return { data, loading, deleteNavNode: mutateAsync };
}

View File

@@ -0,0 +1,13 @@
export interface DatasetNavNode {
name: string;
description: string;
doc_count: number;
type: string;
doc_id?: string; // only returned by the children endpoint
has_children: boolean;
}
export interface DatasetNavList {
total: number;
items: DatasetNavNode[];
}

View File

@@ -517,6 +517,7 @@ Example: A 1 KB message with 1024-dim embedding uses ~9 KB. The 5 MB default lim
graph: 'Graph',
graphPlaceholder: 'Graph view placeholder',
llmWiki: 'LLM Wiki',
navTree: 'Tree',
contents: 'Navigation',
topics: 'Topics',
concept: 'Concept',
@@ -527,6 +528,7 @@ Example: A 1 KB message with 1024-dim embedding uses ~9 KB. The 5 MB default lim
directoryRule: 'Rule',
directoryRulePlaceholder: 'Input',
selectArtifact: 'Select an item from the contents to view details',
searchEntity: 'Search entity',
sourceDocuments: 'Source documents',
compilationTitleSuffix: "' dataset",
name: 'Name',
@@ -2076,6 +2078,20 @@ Example: Virtual Hosted Style`,
deleteSkillTitle: 'Delete skill',
deleteSkillDescription: 'Are you sure you want to delete this skill?',
},
datasetNav: {
title: 'Navigation tree',
empty: 'No navigation nodes',
loading: 'Loading...',
selectNode: 'Select a child node to view details',
noDescription: 'No description',
docCount: '{{count}} documents',
deleteAllTitle: 'Delete navigation tree',
deleteAllDescription:
'Are you sure you want to delete the entire navigation tree? This action cannot be undone.',
deleteNodeTitle: 'Delete node',
deleteNodeDescription:
'Are you sure you want to delete this node and its children?',
},
message: {
registered: 'Registered!',
logout: 'logout',

View File

@@ -463,6 +463,7 @@ export default {
graph: '图谱',
graphPlaceholder: '图谱视图占位',
llmWiki: 'LLM Wiki',
navTree: '目录树',
contents: '导航',
topics: '主题',
concept: '概念',
@@ -473,6 +474,7 @@ export default {
directoryRulePlaceholder: '输入',
entity: '实体',
selectArtifact: '从目录中选择一个条目以查看详情',
searchEntity: '搜索实体',
sourceDocuments: '来源文档',
compilationTitleSuffix: '的数据集',
files: '个文件',
@@ -1737,6 +1739,18 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
deleteSkillTitle: '删除技能',
deleteSkillDescription: '确定要删除该技能吗?',
},
datasetNav: {
title: '目录树',
empty: '暂无导航节点',
loading: '加载中...',
selectNode: '选择子节点以查看详情',
noDescription: '暂无描述',
docCount: '{{count}} 个文档',
deleteAllTitle: '删除目录树',
deleteAllDescription: '确定要删除整个目录树吗?此操作无法撤销。',
deleteNodeTitle: '删除节点',
deleteNodeDescription: '确定要删除该节点及其子节点吗?',
},
message: {
registered: '注册成功',
logout: '登出成功',

View File

@@ -1,15 +1,30 @@
import { useCompilationTemplateGroupOptions } from '@/hooks/use-compilation-template-group-request';
import { IRagNode } from '@/interfaces/database/agent';
import { NodeProps } from '@xyflow/react';
import { get } from 'lodash';
import { LLMLabelCard } from './card';
import { LabelCard, LLMLabelCard } from './card';
import { RagNode } from './index';
import { useTranslation } from 'react-i18next';
export function CompilationNode({ ...props }: NodeProps<IRagNode>) {
const { data } = props;
const { t } = useTranslation();
const options = useCompilationTemplateGroupOptions();
const groupId = get(data, 'form.compilation_template_group_ids');
const groupName =
options.find((option) => option.value === groupId)?.label ?? groupId;
return (
<RagNode {...props}>
<LLMLabelCard llmId={get(data, 'form.llm_id')}></LLMLabelCard>
<section className="flex flex-col gap-2">
<LLMLabelCard llmId={get(data, 'form.llm_id')}></LLMLabelCard>
<LabelCard className="text-text-primary flex justify-between flex-col gap-1">
<span className="text-text-secondary">
{t('knowledgeConfiguration.compilationTemplate')}
</span>
<div className="truncate">{groupName}</div>
</LabelCard>
</section>
</RagNode>
);
}

View File

@@ -1,6 +1,7 @@
export enum ViewMode {
LlmWiki = 'llm-wiki',
Skills = 'skills',
Tree = 'tree',
}
export enum LeftPanelTab {

View File

@@ -0,0 +1,146 @@
import {
useDeleteDatasetNav,
useDeleteDatasetNavNode,
useFetchDatasetNav,
useFetchDatasetNavChildren,
} from '@/hooks/use-dataset-nav-request';
import { DatasetNavNode } from '@/interfaces/database/dataset-nav';
import { useCallback, useEffect, useState } from 'react';
export interface SelectedNavNode {
parentName: string;
name: string;
description: string;
doc_count: number;
}
export function useCompilationNav() {
const { data: navList, loading: navLoading } = useFetchDatasetNav();
const { deleteNav, loading: deleteNavLoading } = useDeleteDatasetNav();
const { deleteNavNode, loading: deleteNodeLoading } =
useDeleteDatasetNavNode();
const [loadingParent, setLoadingParent] = useState<string | null>(null);
const [childrenMap, setChildrenMap] = useState<
Record<string, DatasetNavNode[]>
>({});
const [selectedNode, setSelectedNode] = useState<SelectedNavNode | null>(
null,
);
const { data: childrenData } = useFetchDatasetNavChildren(loadingParent);
useEffect(() => {
if (loadingParent && childrenData) {
setChildrenMap((prev) => ({
...prev,
[loadingParent]: childrenData.items,
}));
}
}, [loadingParent, childrenData]);
const loadChildren = useCallback(
(name: string) => {
if (!(name in childrenMap)) {
setLoadingParent(name);
}
},
[childrenMap],
);
const removeChild = useCallback((parentName: string, childName: string) => {
setChildrenMap((prev) => {
const children = prev[parentName];
if (!children) {
return prev;
}
return {
...prev,
[parentName]: children.filter((node) => node.name !== childName),
};
});
}, []);
const dropChildren = useCallback((name: string) => {
setChildrenMap((prev) => {
if (!(name in prev)) {
return prev;
}
const next = { ...prev };
delete next[name];
return next;
});
}, []);
const resetNav = useCallback(() => {
setSelectedNode(null);
setChildrenMap({});
setLoadingParent(null);
}, []);
const handleParentClick = useCallback(
(node: DatasetNavNode) => {
setSelectedNode(null);
if (node.has_children) {
loadChildren(node.name);
}
},
[loadChildren],
);
const handleChildClick = useCallback(
(node: DatasetNavNode, parentName: string) => {
setSelectedNode({
parentName,
name: node.name,
description: node.description,
doc_count: node.doc_count,
});
},
[],
);
const handleDeleteAll = useCallback(async () => {
const data = await deleteNav();
if (data?.code === 0) {
resetNav();
}
}, [deleteNav, resetNav]);
const handleDeleteNode = useCallback(
async (name: string, parentName: string | null) => {
const data = await deleteNavNode(name);
if (data?.code === 0) {
if (parentName) {
// The children query of this parent may have no active observer, so
// invalidation alone would not refetch — filter the local map too.
removeChild(parentName, name);
setSelectedNode((current) =>
current?.parentName === parentName && current?.name === name
? null
: current,
);
} else {
dropChildren(name);
setSelectedNode((current) =>
current?.parentName === name ? null : current,
);
}
}
},
[deleteNavNode, removeChild, dropChildren],
);
return {
navList,
navLoading,
childrenMap,
selectedNode,
deleteNavLoading,
deleteNodeLoading,
handleParentClick,
handleChildClick,
handleDeleteAll,
handleDeleteNode,
};
}

View File

@@ -9,6 +9,7 @@ import { useParams } from 'react-router';
import { ViewMode } from './constants';
import { LlmWikiView } from './llm-wiki-view';
import { NavTreeView } from './nav-tree-view';
import { SkillsView } from './skills-view';
export default function Compilation() {
@@ -26,6 +27,10 @@ export default function Compilation() {
setViewMode(ViewMode.Skills);
}, []);
const handleSwitchToTree = useCallback(() => {
setViewMode(ViewMode.Tree);
}, []);
return (
<section className="flex flex-col p-4 gap-4 h-full">
<header className="space-y-5">
@@ -62,11 +67,21 @@ export default function Compilation() {
>
To Skills
</Button>
<Button
variant={viewMode === ViewMode.Tree ? 'default' : 'outline'}
size="sm"
onClick={handleSwitchToTree}
>
{t('knowledgeDetails.navTree')}
</Button>
</div>
</section>
</header>
{viewMode === ViewMode.LlmWiki ? <LlmWikiView /> : <SkillsView />}
{viewMode === ViewMode.LlmWiki && <LlmWikiView />}
{viewMode === ViewMode.Skills && <SkillsView />}
{viewMode === ViewMode.Tree && <NavTreeView />}
</section>
);
}

View File

@@ -86,6 +86,7 @@ export function LlmWikiView() {
onTabChange={handleLeftTabChange}
selectedArtifact={selectedArtifact}
onSelectArtifact={handleSelectArtifact}
onClearArtifact={clearSelectedArtifact}
onClearWiki={clearSelectedArtifact}
/>
</ResizablePanel>

View File

@@ -0,0 +1,163 @@
import { ConfirmDeleteDialog } from '@/components/confirm-delete-dialog';
import { Button } from '@/components/ui/button';
import { Spin } from '@/components/ui/spin';
import { TreeView } from '@/components/ui/tree-view';
import {
DatasetNavList,
DatasetNavNode,
} from '@/interfaces/database/dataset-nav';
import { FileText, Folder, Trash2 } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { buildNavTreeData } from './utils/nav-tree';
type NavNodeDeleteActionProps = {
name: string;
parentName: string | null;
deleteLoading: boolean;
onDelete: (name: string, parentName: string | null) => void;
};
function NavNodeDeleteAction({
name,
parentName,
deleteLoading,
onDelete,
}: NavNodeDeleteActionProps) {
const { t } = useTranslation();
const handleTriggerClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
// TreeView does not guard action clicks: without this the row would
// also get selected and a branch row would toggle its accordion.
e.stopPropagation();
},
[],
);
const handleConfirmDelete = useCallback(() => {
onDelete(name, parentName);
}, [name, parentName, onDelete]);
return (
<ConfirmDeleteDialog
title={t('datasetNav.deleteNodeTitle')}
content={{ title: t('datasetNav.deleteNodeDescription') }}
onOk={handleConfirmDelete}
>
<Button
variant="ghost"
size="icon-sm"
disabled={deleteLoading}
onClick={handleTriggerClick}
// TreeActions keeps actions always visible on the selected row;
// hide again so the button only appears while hovering the row.
// `hidden` (not opacity-0) so no invisible click target remains.
className="hidden group-hover:inline-flex"
>
<Trash2 />
</Button>
</ConfirmDeleteDialog>
);
}
type NavTreeLeftPanelProps = {
navList: DatasetNavList | null;
navLoading: boolean;
childrenMap: Record<string, DatasetNavNode[]>;
deleteNavLoading: boolean;
deleteNodeLoading: boolean;
onParentClick: (node: DatasetNavNode) => void;
onChildClick: (node: DatasetNavNode, parentName: string) => void;
onDeleteAll: () => void;
onDeleteNode: (name: string, parentName: string | null) => void;
};
export function NavTreeLeftPanel({
navList,
navLoading,
childrenMap,
deleteNavLoading,
deleteNodeLoading,
onParentClick,
onChildClick,
onDeleteAll,
onDeleteNode,
}: NavTreeLeftPanelProps) {
const { t } = useTranslation();
const renderNavActions = useCallback(
(node: DatasetNavNode, parentName: string | null) => (
<NavNodeDeleteAction
name={node.name}
parentName={parentName}
deleteLoading={deleteNodeLoading}
onDelete={onDeleteNode}
/>
),
[deleteNodeLoading, onDeleteNode],
);
const treeData = useMemo(
() =>
buildNavTreeData(navList?.items, {
childrenMap,
getActions: renderNavActions,
onParentClick,
onChildClick,
loadingPlaceholder: t('datasetNav.loading'),
}),
[
navList?.items,
childrenMap,
renderNavActions,
onParentClick,
onChildClick,
t,
],
);
return (
<aside className="size-full flex flex-col">
<section className="flex items-center justify-between px-3 pt-3">
<span className="text-sm font-medium text-text-primary">
{t('datasetNav.title')} ({navList?.total ?? 0})
</span>
{treeData.length > 0 && (
<ConfirmDeleteDialog
title={t('datasetNav.deleteAllTitle')}
content={{ title: t('datasetNav.deleteAllDescription') }}
onOk={onDeleteAll}
>
<Button
variant="ghost"
size="icon-sm"
disabled={deleteNavLoading}
data-testid="nav-tree-clear-trigger"
>
<Trash2 />
</Button>
</ConfirmDeleteDialog>
)}
</section>
<div className="flex-1 min-h-0 overflow-y-auto px-1 pt-2 pb-3">
{navLoading && treeData.length === 0 ? (
<div className="py-8 flex justify-center">
<Spin size="small" />
</div>
) : treeData.length === 0 ? (
<div className="py-8 text-center text-sm text-text-secondary">
{t('datasetNav.empty')}
</div>
) : (
<TreeView
data={treeData}
defaultNodeIcon={Folder}
defaultLeafIcon={FileText}
/>
)}
</div>
</aside>
);
}

View File

@@ -0,0 +1,68 @@
import { Card } from '@/components/ui/card';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable';
import { useTranslation } from 'react-i18next';
import { useCompilationNav } from './hooks/use-compilation-nav';
import { NavTreeLeftPanel } from './nav-tree-left-panel';
export function NavTreeView() {
const { t } = useTranslation();
const {
navList,
navLoading,
childrenMap,
selectedNode,
deleteNavLoading,
deleteNodeLoading,
handleParentClick,
handleChildClick,
handleDeleteAll,
handleDeleteNode,
} = useCompilationNav();
return (
<Card className="flex-1 min-h-0 overflow-hidden flex border-border-button rounded-xl flex-col">
<ResizablePanelGroup direction="horizontal" className="flex-1">
<ResizablePanel defaultSize={33} minSize={20} maxSize={50}>
<NavTreeLeftPanel
navList={navList}
navLoading={navLoading}
childrenMap={childrenMap}
deleteNavLoading={deleteNavLoading}
deleteNodeLoading={deleteNodeLoading}
onParentClick={handleParentClick}
onChildClick={handleChildClick}
onDeleteAll={handleDeleteAll}
onDeleteNode={handleDeleteNode}
/>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel className="flex flex-col">
{selectedNode ? (
<section className="flex flex-col h-full">
<header className="px-4 py-3 border-b border-border-button space-y-1">
<h3 className="text-sm font-medium text-text-primary">
{selectedNode.name}
</h3>
<span className="text-xs text-text-secondary">
{t('datasetNav.docCount', { count: selectedNode.doc_count })}
</span>
</header>
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-3 text-sm text-text-primary whitespace-pre-wrap">
{selectedNode.description || t('datasetNav.noDescription')}
</div>
</section>
) : (
<div className="flex-1 flex items-center justify-center text-sm text-text-secondary">
{t('datasetNav.selectNode')}
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
</Card>
);
}

View File

@@ -0,0 +1,57 @@
import { TreeDataItem } from '@/components/ui/tree-view';
import { DatasetNavNode } from '@/interfaces/database/dataset-nav';
import { ReactNode } from 'react';
export type NavTreeActionsFactory = (
node: DatasetNavNode,
parentName: string | null,
) => ReactNode;
type BuildNavTreeDataOptions = {
childrenMap: Record<string, DatasetNavNode[]>;
getActions?: NavTreeActionsFactory;
onParentClick: (node: DatasetNavNode) => void;
onChildClick: (node: DatasetNavNode, parentName: string) => void;
loadingPlaceholder: string;
};
export function buildNavTreeData(
items: DatasetNavNode[] = [],
{
childrenMap,
getActions,
onParentClick,
onChildClick,
loadingPlaceholder,
}: BuildNavTreeDataOptions,
): TreeDataItem[] {
return items.map((node) => {
const item: TreeDataItem = {
id: node.name,
name: node.name,
actions: getActions?.(node, null),
onClick: () => onParentClick(node),
};
if (node.has_children) {
const children = childrenMap[node.name];
if (children?.length) {
item.children = children.map((child) => ({
id: `${node.name}/${child.name}`,
name: child.name,
actions: getActions?.(child, node.name),
onClick: () => onChildClick(child, node.name),
}));
} else if (!children) {
// Children not fetched yet: a placeholder keeps the node rendered as
// an expandable branch until the request resolves.
item.children = [
{ id: `${node.name}/__loading__`, name: loadingPlaceholder },
];
}
// Fetched but empty: leave children unset so the node becomes a leaf.
}
return item;
});
}

View File

@@ -15,7 +15,6 @@ export function useWikiNavigation() {
const [selectedTopic, setSelectedTopic] = useState<IArtifactTopic | null>(
null,
);
const [pageType, setPageType] = useState<WikiPageType>('entity');
const {
topics,
@@ -34,7 +33,6 @@ export function useWikiNavigation() {
} = useFetchArtifactList({
keywords: debouncedSearchString,
topic: selectedTopic?.topic,
pageType,
enabled: !!selectedTopic,
});
@@ -64,10 +62,6 @@ export function useWikiNavigation() {
resetScroll();
}, [resetScroll]);
const handlePageTypeChange = useCallback((value: WikiPageType) => {
setPageType(value);
}, []);
const handleScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
if (selectedTopic) {
@@ -88,7 +82,6 @@ export function useWikiNavigation() {
searchString,
debouncedSearchString,
selectedTopic,
pageType,
topics,
artifacts,
loading,
@@ -96,14 +89,12 @@ export function useWikiNavigation() {
handleSearchChange,
handleSelectTopic,
handleBackToTopics,
handlePageTypeChange,
handleScroll,
}),
[
searchString,
debouncedSearchString,
selectedTopic,
pageType,
topics,
artifacts,
loading,
@@ -111,7 +102,6 @@ export function useWikiNavigation() {
handleSearchChange,
handleSelectTopic,
handleBackToTopics,
handlePageTypeChange,
handleScroll,
],
);

View File

@@ -1,10 +1,15 @@
import ArtifactForceGraph from '@/components/artifact-force-graph';
import { ConfirmDeleteDialog } from '@/components/confirm-delete-dialog';
import {
SelectWithSearch,
SelectWithSearchFlagOptionType,
} from '@/components/originui/select-with-search';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useFetchArtifactGraph } from '@/hooks/use-knowledge-request';
import { IArtifact, IArtifactGraphEntity } from '@/interfaces/database/dataset';
import { Trash2 } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { LeftPanelTab } from '../constants';
@@ -22,6 +27,7 @@ type WikiLeftPanelProps = {
onTabChange: (value: string) => void;
selectedArtifact: IArtifact | null;
onSelectArtifact: (artifact: IArtifact) => void;
onClearArtifact: () => void;
onClearWiki?: () => void;
};
@@ -30,6 +36,7 @@ export function WikiLeftPanel({
onTabChange,
selectedArtifact,
onSelectArtifact,
onClearArtifact,
onClearWiki,
}: WikiLeftPanelProps) {
const { t } = useTranslation();
@@ -40,6 +47,37 @@ export function WikiLeftPanel({
onClearWiki,
});
const entityOptions = useMemo<SelectWithSearchFlagOptionType[]>(
() =>
data.entities.map((entity) => ({
label: entity.name,
value: entity.slug,
keywords: [entity.name, ...entity.aliases],
})),
[data.entities],
);
// Only refill the select when selectedArtifact is a graph entity, to avoid showing the raw slug
const selectedEntitySlug = data.entities.some(
(entity) => entity.slug === selectedArtifact?.slug,
)
? (selectedArtifact?.slug ?? '')
: '';
const handleSelectEntity = useCallback(
(slug: string) => {
if (!slug) {
onClearArtifact();
return;
}
const entity = data.entities.find((item) => item.slug === slug);
if (entity) {
onSelectArtifact(mapNodeToValue(entity));
}
},
[data.entities, onSelectArtifact, onClearArtifact],
);
return (
<aside className="size-full flex flex-col p-5">
<section className="flex items-center justify-between pb-5">
@@ -79,12 +117,23 @@ export function WikiLeftPanel({
/>
)}
{tab === LeftPanelTab.Graph && (
<ArtifactForceGraph
data={data}
show
mapNodeToValue={mapNodeToValue}
onNodeClick={onSelectArtifact}
/>
<div className="flex h-full flex-col gap-3">
<SelectWithSearch
options={entityOptions}
value={selectedEntitySlug}
onChange={handleSelectEntity}
placeholder={t('knowledgeDetails.searchEntity')}
allowClear
triggerClassName="w-96 max-w-full"
/>
<ArtifactForceGraph
data={data}
show
mapNodeToValue={mapNodeToValue}
onNodeClick={onSelectArtifact}
highlightNodeId={selectedArtifact?.slug}
/>
</div>
)}
</div>
</aside>

View File

@@ -10,51 +10,14 @@ import { SearchInput } from '@/components/ui/input';
import { IArtifact } from '@/interfaces/database/dataset';
import { cn } from '@/lib/utils';
import { Plus } from 'lucide-react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { CreateDirectoryDialog } from '../create-directory-dialog';
import { useCreateDirectory } from '../hooks/use-create-directory';
import { useWikiNavigation, WikiPageType } from './hooks/use-wiki-navigation';
import { useWikiNavigation } from './hooks/use-wiki-navigation';
import { WikiArtifactList } from './wiki-artifact-list';
import { WikiTopicList } from './wiki-topic-list';
type PageTypeFilterProps = {
value: WikiPageType;
onChange: (value: WikiPageType) => void;
};
function PageTypeFilter({ value, onChange }: PageTypeFilterProps) {
const { t } = useTranslation();
const handleConceptClick = useCallback(() => {
onChange('concept');
}, [onChange]);
const handleEntityClick = useCallback(() => {
onChange('entity');
}, [onChange]);
return (
<div className="flex items-center gap-2">
<Button
variant={value === 'concept' ? 'default' : 'outline'}
size="sm"
onClick={handleConceptClick}
>
{t('knowledgeDetails.concept')}
</Button>
<Button
variant={value === 'entity' ? 'default' : 'outline'}
size="sm"
onClick={handleEntityClick}
>
{t('knowledgeDetails.entity')}
</Button>
</div>
);
}
type WikiNavBarProps = {
selectedArtifact: IArtifact | null;
onSelectArtifact: (artifact: IArtifact) => void;
@@ -69,7 +32,6 @@ export function WikiNavBar({
scrollRef,
searchString,
selectedTopic,
pageType,
topics,
artifacts,
loading,
@@ -77,7 +39,6 @@ export function WikiNavBar({
handleSearchChange,
handleSelectTopic,
handleBackToTopics,
handlePageTypeChange,
handleScroll,
} = useWikiNavigation();
const {
@@ -96,7 +57,6 @@ export function WikiNavBar({
value={searchString}
onChange={handleSearchChange}
/>
<PageTypeFilter value={pageType} onChange={handlePageTypeChange} />
<section>
<div className="flex items-center justify-between">
<Breadcrumb>

View File

@@ -12,7 +12,10 @@ import { useTranslation } from 'react-i18next';
import { formatKindLabel } from '@/utils/compilation-template-util';
import { FormSchemaType } from '@/pages/user-setting/compilation-templates/create-next/schema';
import { DefaultTemplateValues } from '@/pages/user-setting/compilation-templates/create-next/utils';
import {
DefaultTemplateValues,
generateTemplateName,
} from '@/pages/user-setting/compilation-templates/create-next/utils';
import { useTemplateAddButton } from '../hooks/use-template-add-button';
@@ -128,11 +131,11 @@ export function TemplateSidebar({
const nextIndex = fields.length;
append({
...DefaultTemplateValues,
name: `${t('setting.template')} #${nextIndex + 1}`,
name: generateTemplateName(),
llm_id: firstTemplateLlmId || '',
});
onSelectTemplate(nextIndex);
}, [append, fields.length, form, onSelectTemplate, t]);
}, [append, fields.length, form, onSelectTemplate]);
const handleRemoveTemplate = useCallback(
(index: number) => {

View File

@@ -6,7 +6,11 @@ import { useTranslation } from 'react-i18next';
import { ICompilationTemplateGroup } from '@/interfaces/database/compilation-template';
import { buildFormSchema, FormSchemaType } from '../schema';
import { DefaultValues, transformGroupDetailToForm } from '../utils';
import {
DefaultValues,
generateTemplateName,
transformGroupDetailToForm,
} from '../utils';
type UseCompilationTemplateGroupFormOptions = {
detail?: ICompilationTemplateGroup;
@@ -28,11 +32,11 @@ export const useCompilationTemplateGroupForm = ({
templates: [
{
...DefaultValues.templates[0],
name: `${t('setting.template')} #1`,
name: generateTemplateName(),
},
],
};
}, [isCreate, t]);
}, [isCreate]);
const form = useForm<FormSchemaType>({
resolver: zodResolver(buildFormSchema(t)),

View File

@@ -1,3 +1,4 @@
import { humanId } from 'human-id';
import { isEqual } from 'lodash';
import {
@@ -43,6 +44,9 @@ export const getFieldKeyOrder = (keys: string[]): string[] => {
);
};
export const generateTemplateName = () =>
humanId({ separator: ' ', capitalize: true });
export const DefaultTemplateValues: TemplateSchemaType = {
id: undefined,
name: '',

View File

@@ -0,0 +1,15 @@
import api from '@/utils/api';
import request from '@/utils/next-request';
const datasetNavService = {
getNav: (params: { datasetId: string }) =>
request.get(api.getDatasetNav(params.datasetId)),
getNavChildren: (params: { datasetId: string; name: string }) =>
request.get(api.getDatasetNavChildren(params.datasetId, params.name)),
deleteNav: (params: { datasetId: string }) =>
request.delete(api.deleteDatasetNav(params.datasetId)),
deleteNavNode: (params: { datasetId: string; name: string }) =>
request.delete(api.deleteDatasetNavNode(params.datasetId, params.name)),
};
export default datasetNavService;

View File

@@ -181,6 +181,20 @@ export default {
.split('/')
.map((s) => encodeURIComponent(s))
.join('/')}`,
getDatasetNav: (datasetId: string) =>
`${restAPIv1}/datasets/${datasetId}/nav`,
getDatasetNavChildren: (datasetId: string, name: string) =>
`${restAPIv1}/datasets/${datasetId}/nav/${name
.split('/')
.map((s) => encodeURIComponent(s))
.join('/')}/children`,
deleteDatasetNav: (datasetId: string) =>
`${restAPIv1}/datasets/${datasetId}/nav`,
deleteDatasetNavNode: (datasetId: string, name: string) =>
`${restAPIv1}/datasets/${datasetId}/nav/${name
.split('/')
.map((s) => encodeURIComponent(s))
.join('/')}`,
// data pipeline log
fetchDataPipelineLog: (datasetId: string) =>
`${restAPIv1}/datasets/${datasetId}/ingestions`,