mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-22 07:31:05 +08:00
Feat: Highlight wiki force graph. (#17160)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
|
||||
103
web/src/hooks/use-dataset-nav-request.ts
Normal file
103
web/src/hooks/use-dataset-nav-request.ts
Normal 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 };
|
||||
}
|
||||
13
web/src/interfaces/database/dataset-nav.ts
Normal file
13
web/src/interfaces/database/dataset-nav.ts
Normal 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[];
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '登出成功',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export enum ViewMode {
|
||||
LlmWiki = 'llm-wiki',
|
||||
Skills = 'skills',
|
||||
Tree = 'tree',
|
||||
}
|
||||
|
||||
export enum LeftPanelTab {
|
||||
|
||||
146
web/src/pages/dataset/compilation/hooks/use-compilation-nav.ts
Normal file
146
web/src/pages/dataset/compilation/hooks/use-compilation-nav.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ export function LlmWikiView() {
|
||||
onTabChange={handleLeftTabChange}
|
||||
selectedArtifact={selectedArtifact}
|
||||
onSelectArtifact={handleSelectArtifact}
|
||||
onClearArtifact={clearSelectedArtifact}
|
||||
onClearWiki={clearSelectedArtifact}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
163
web/src/pages/dataset/compilation/nav-tree-left-panel.tsx
Normal file
163
web/src/pages/dataset/compilation/nav-tree-left-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
web/src/pages/dataset/compilation/nav-tree-view.tsx
Normal file
68
web/src/pages/dataset/compilation/nav-tree-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
web/src/pages/dataset/compilation/utils/nav-tree.ts
Normal file
57
web/src/pages/dataset/compilation/utils/nav-tree.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
15
web/src/services/dataset-nav-service.ts
Normal file
15
web/src/services/dataset-nav-service.ts
Normal 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;
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user