From 20b760f266a06e03763c451166fb19f32f2614bf Mon Sep 17 00:00:00 2001 From: balibabu Date: Tue, 21 Jul 2026 16:48:52 +0800 Subject: [PATCH] Feat: Highlight wiki force graph. (#17160) --- .../components/artifact-force-graph/index.tsx | 58 +++++-- .../artifact-force-graph/node-style.ts | 5 + .../components/artifact-force-graph/types.ts | 11 +- .../use-artifact-graph-data.ts | 26 ++- .../use-graph-highlight.ts | 89 ++++++++++ .../components/artifact-force-graph/utils.ts | 15 ++ .../compilation-template-form-field.tsx | 10 +- .../use-compilation-template-group-request.ts | 9 + web/src/hooks/use-dataset-nav-request.ts | 103 +++++++++++ web/src/interfaces/database/dataset-nav.ts | 13 ++ web/src/locales/en.ts | 16 ++ web/src/locales/zh.ts | 14 ++ .../agent/canvas/node/compilation-node.tsx | 19 +- .../pages/dataset/compilation/constants.ts | 1 + .../compilation/hooks/use-compilation-nav.ts | 146 ++++++++++++++++ web/src/pages/dataset/compilation/index.tsx | 17 +- .../dataset/compilation/llm-wiki-view.tsx | 1 + .../compilation/nav-tree-left-panel.tsx | 163 ++++++++++++++++++ .../dataset/compilation/nav-tree-view.tsx | 68 ++++++++ .../dataset/compilation/utils/nav-tree.ts | 57 ++++++ .../hooks/use-wiki-navigation.ts | 10 -- .../compilation/wiki-left-panel/index.tsx | 61 ++++++- .../wiki-left-panel/wiki-nav-bar.tsx | 42 +---- .../components/template-sidebar.tsx | 9 +- .../use-compilation-template-group-form.ts | 10 +- .../create-next/utils.ts | 4 + web/src/services/dataset-nav-service.ts | 15 ++ web/src/utils/api.ts | 14 ++ 28 files changed, 908 insertions(+), 98 deletions(-) create mode 100644 web/src/components/artifact-force-graph/use-graph-highlight.ts create mode 100644 web/src/hooks/use-dataset-nav-request.ts create mode 100644 web/src/interfaces/database/dataset-nav.ts create mode 100644 web/src/pages/dataset/compilation/hooks/use-compilation-nav.ts create mode 100644 web/src/pages/dataset/compilation/nav-tree-left-panel.tsx create mode 100644 web/src/pages/dataset/compilation/nav-tree-view.tsx create mode 100644 web/src/pages/dataset/compilation/utils/nav-tree.ts create mode 100644 web/src/services/dataset-nav-service.ts diff --git a/web/src/components/artifact-force-graph/index.tsx b/web/src/components/artifact-force-graph/index.tsx index e986a02cf0..bf9eebaf3c 100644 --- a/web/src/components/artifact-force-graph/index.tsx +++ b/web/src/components/artifact-force-graph/index.tsx @@ -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({ data, show = true, @@ -20,9 +24,10 @@ function ArtifactForceGraph({ mapNodeToValue = defaultMapNodeToValue as ( node: IArtifactGraphEntity, ) => TNodeValue, - getNodeId = (node) => node.slug, + getNodeId = defaultGetNodeId, getNodeColor = defaultGetNodeColor, getNodeRadius = defaultGetNodeRadius, + highlightNodeId, }: ArtifactForceGraphProps) { const containerRef = useRef(null); const fgRef = useRef | undefined>( @@ -38,6 +43,34 @@ function ArtifactForceGraph({ 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({ [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({ 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} /> )} diff --git a/web/src/components/artifact-force-graph/node-style.ts b/web/src/components/artifact-force-graph/node-style.ts index a64c4fe343..e1410754bc 100644 --- a/web/src/components/artifact-force-graph/node-style.ts +++ b/web/src/components/artifact-force-graph/node-style.ts @@ -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; diff --git a/web/src/components/artifact-force-graph/types.ts b/web/src/components/artifact-force-graph/types.ts index 836db70e4c..278cf4a0a7 100644 --- a/web/src/components/artifact-force-graph/types.ts +++ b/web/src/components/artifact-force-graph/types.ts @@ -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 & { id: string; __color: string; __radius: number; + __neighbors?: ArtifactGraphNode[]; + __links?: ArtifactGraphLink[]; }; +export type ArtifactGraphLink = LinkObject< + ArtifactGraphNode, + { source: string; target: string } +>; + export interface ArtifactForceGraphProps { data?: IArtifactGraph; show?: boolean; @@ -22,4 +29,6 @@ export interface ArtifactForceGraphProps { 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; } diff --git a/web/src/components/artifact-force-graph/use-artifact-graph-data.ts b/web/src/components/artifact-force-graph/use-artifact-graph-data.ts index bb29fcbd57..1835c955a2 100644 --- a/web/src/components/artifact-force-graph/use-artifact-graph-data.ts +++ b/web/src/components/artifact-force-graph/use-artifact-graph-data.ts @@ -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]); diff --git a/web/src/components/artifact-force-graph/use-graph-highlight.ts b/web/src/components/artifact-force-graph/use-graph-highlight.ts new file mode 100644 index 0000000000..6a555c6497 --- /dev/null +++ b/web/src/components/artifact-force-graph/use-graph-highlight.ts @@ -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['nodeCanvasObject'] +>; + +export const useGraphHighlight = ( + getBaseLinkColor: () => string, + pinnedNode?: ArtifactGraphNode | null, +) => { + const [hoverNode, setHoverNode] = useState(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(); + if (activeNode) { + nodes.add(activeNode); + activeNode.__neighbors?.forEach((neighbor) => nodes.add(neighbor)); + } + return nodes; + }, [activeNode]); + + const highlightLinks = useMemo( + () => new Set(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( + (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, + }; +}; diff --git a/web/src/components/artifact-force-graph/utils.ts b/web/src/components/artifact-force-graph/utils.ts index 3fdde0dd55..0dd25a5594 100644 --- a/web/src/components/artifact-force-graph/utils.ts +++ b/web/src/components/artifact-force-graph/utils.ts @@ -3,3 +3,18 @@ import { type IArtifactGraphEntity } from '@/interfaces/database/dataset'; export const defaultMapNodeToValue = ( 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; +}; diff --git a/web/src/components/compilation-template-form-field.tsx b/web/src/components/compilation-template-form-field.tsx index 4e0ce33df1..1d9175fd07 100644 --- a/web/src/components/compilation-template-form-field.tsx +++ b/web/src/components/compilation-template-form-field.tsx @@ -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 ( { return { groups: data ?? [], loading }; }; + +export const useCompilationTemplateGroupOptions = () => { + const { groups } = useFetchAllCompilationTemplateGroups(); + + return useMemo( + () => groups.map((group) => ({ label: group.name, value: group.id })), + [groups], + ); +}; diff --git a/web/src/hooks/use-dataset-nav-request.ts b/web/src/hooks/use-dataset-nav-request.ts new file mode 100644 index 0000000000..7bb9db8629 --- /dev/null +++ b/web/src/hooks/use-dataset-nav-request.ts @@ -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({ + 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({ + 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 }; +} diff --git a/web/src/interfaces/database/dataset-nav.ts b/web/src/interfaces/database/dataset-nav.ts new file mode 100644 index 0000000000..de03fa81ce --- /dev/null +++ b/web/src/interfaces/database/dataset-nav.ts @@ -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[]; +} diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index cf0d730666..4c21394786 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -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', diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index 553405a06b..103da3f43a 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -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: '登出成功', diff --git a/web/src/pages/agent/canvas/node/compilation-node.tsx b/web/src/pages/agent/canvas/node/compilation-node.tsx index 6180ecbccd..6f5009f9a1 100644 --- a/web/src/pages/agent/canvas/node/compilation-node.tsx +++ b/web/src/pages/agent/canvas/node/compilation-node.tsx @@ -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) { 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 ( - +
+ + + + {t('knowledgeConfiguration.compilationTemplate')} + +
{groupName}
+
+
); } diff --git a/web/src/pages/dataset/compilation/constants.ts b/web/src/pages/dataset/compilation/constants.ts index 10981bf5b9..72c32704b9 100644 --- a/web/src/pages/dataset/compilation/constants.ts +++ b/web/src/pages/dataset/compilation/constants.ts @@ -1,6 +1,7 @@ export enum ViewMode { LlmWiki = 'llm-wiki', Skills = 'skills', + Tree = 'tree', } export enum LeftPanelTab { diff --git a/web/src/pages/dataset/compilation/hooks/use-compilation-nav.ts b/web/src/pages/dataset/compilation/hooks/use-compilation-nav.ts new file mode 100644 index 0000000000..70e5c01537 --- /dev/null +++ b/web/src/pages/dataset/compilation/hooks/use-compilation-nav.ts @@ -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(null); + const [childrenMap, setChildrenMap] = useState< + Record + >({}); + const [selectedNode, setSelectedNode] = useState( + 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, + }; +} diff --git a/web/src/pages/dataset/compilation/index.tsx b/web/src/pages/dataset/compilation/index.tsx index 80054c452b..d6378cfd4d 100644 --- a/web/src/pages/dataset/compilation/index.tsx +++ b/web/src/pages/dataset/compilation/index.tsx @@ -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 (
@@ -62,11 +67,21 @@ export default function Compilation() { > To Skills + +
- {viewMode === ViewMode.LlmWiki ? : } + {viewMode === ViewMode.LlmWiki && } + {viewMode === ViewMode.Skills && } + {viewMode === ViewMode.Tree && } ); } diff --git a/web/src/pages/dataset/compilation/llm-wiki-view.tsx b/web/src/pages/dataset/compilation/llm-wiki-view.tsx index 55f7589c1f..9c0ed739f8 100644 --- a/web/src/pages/dataset/compilation/llm-wiki-view.tsx +++ b/web/src/pages/dataset/compilation/llm-wiki-view.tsx @@ -86,6 +86,7 @@ export function LlmWikiView() { onTabChange={handleLeftTabChange} selectedArtifact={selectedArtifact} onSelectArtifact={handleSelectArtifact} + onClearArtifact={clearSelectedArtifact} onClearWiki={clearSelectedArtifact} /> diff --git a/web/src/pages/dataset/compilation/nav-tree-left-panel.tsx b/web/src/pages/dataset/compilation/nav-tree-left-panel.tsx new file mode 100644 index 0000000000..2c71f59353 --- /dev/null +++ b/web/src/pages/dataset/compilation/nav-tree-left-panel.tsx @@ -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) => { + // 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 ( + + + + ); +} + +type NavTreeLeftPanelProps = { + navList: DatasetNavList | null; + navLoading: boolean; + childrenMap: Record; + 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) => ( + + ), + [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 ( + + ); +} diff --git a/web/src/pages/dataset/compilation/nav-tree-view.tsx b/web/src/pages/dataset/compilation/nav-tree-view.tsx new file mode 100644 index 0000000000..02868f1f79 --- /dev/null +++ b/web/src/pages/dataset/compilation/nav-tree-view.tsx @@ -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 ( + + + + + + + + {selectedNode ? ( +
+
+

+ {selectedNode.name} +

+ + {t('datasetNav.docCount', { count: selectedNode.doc_count })} + +
+
+ {selectedNode.description || t('datasetNav.noDescription')} +
+
+ ) : ( +
+ {t('datasetNav.selectNode')} +
+ )} +
+
+
+ ); +} diff --git a/web/src/pages/dataset/compilation/utils/nav-tree.ts b/web/src/pages/dataset/compilation/utils/nav-tree.ts new file mode 100644 index 0000000000..43aeaa387f --- /dev/null +++ b/web/src/pages/dataset/compilation/utils/nav-tree.ts @@ -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; + 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; + }); +} diff --git a/web/src/pages/dataset/compilation/wiki-left-panel/hooks/use-wiki-navigation.ts b/web/src/pages/dataset/compilation/wiki-left-panel/hooks/use-wiki-navigation.ts index c1696ad136..c37fa40208 100644 --- a/web/src/pages/dataset/compilation/wiki-left-panel/hooks/use-wiki-navigation.ts +++ b/web/src/pages/dataset/compilation/wiki-left-panel/hooks/use-wiki-navigation.ts @@ -15,7 +15,6 @@ export function useWikiNavigation() { const [selectedTopic, setSelectedTopic] = useState( null, ); - const [pageType, setPageType] = useState('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) => { 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, ], ); diff --git a/web/src/pages/dataset/compilation/wiki-left-panel/index.tsx b/web/src/pages/dataset/compilation/wiki-left-panel/index.tsx index e9cf875520..ddb8f11e0e 100644 --- a/web/src/pages/dataset/compilation/wiki-left-panel/index.tsx +++ b/web/src/pages/dataset/compilation/wiki-left-panel/index.tsx @@ -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( + () => + 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 ( diff --git a/web/src/pages/dataset/compilation/wiki-left-panel/wiki-nav-bar.tsx b/web/src/pages/dataset/compilation/wiki-left-panel/wiki-nav-bar.tsx index a36daf7ded..4befda5d2e 100644 --- a/web/src/pages/dataset/compilation/wiki-left-panel/wiki-nav-bar.tsx +++ b/web/src/pages/dataset/compilation/wiki-left-panel/wiki-nav-bar.tsx @@ -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 ( -
- - -
- ); -} - 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} /> -
diff --git a/web/src/pages/user-setting/compilation-templates/create-next/components/template-sidebar.tsx b/web/src/pages/user-setting/compilation-templates/create-next/components/template-sidebar.tsx index 3e61552919..de2d1dbdab 100644 --- a/web/src/pages/user-setting/compilation-templates/create-next/components/template-sidebar.tsx +++ b/web/src/pages/user-setting/compilation-templates/create-next/components/template-sidebar.tsx @@ -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) => { diff --git a/web/src/pages/user-setting/compilation-templates/create-next/hooks/use-compilation-template-group-form.ts b/web/src/pages/user-setting/compilation-templates/create-next/hooks/use-compilation-template-group-form.ts index 3601964640..8a7ccc7458 100644 --- a/web/src/pages/user-setting/compilation-templates/create-next/hooks/use-compilation-template-group-form.ts +++ b/web/src/pages/user-setting/compilation-templates/create-next/hooks/use-compilation-template-group-form.ts @@ -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({ resolver: zodResolver(buildFormSchema(t)), diff --git a/web/src/pages/user-setting/compilation-templates/create-next/utils.ts b/web/src/pages/user-setting/compilation-templates/create-next/utils.ts index 1c4a7dc069..834430cbd8 100644 --- a/web/src/pages/user-setting/compilation-templates/create-next/utils.ts +++ b/web/src/pages/user-setting/compilation-templates/create-next/utils.ts @@ -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: '', diff --git a/web/src/services/dataset-nav-service.ts b/web/src/services/dataset-nav-service.ts new file mode 100644 index 0000000000..cef2bd04e3 --- /dev/null +++ b/web/src/services/dataset-nav-service.ts @@ -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; diff --git a/web/src/utils/api.ts b/web/src/utils/api.ts index 32ea4ad877..1365be46fd 100644 --- a/web/src/utils/api.ts +++ b/web/src/utils/api.ts @@ -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`,