### What problem does this PR solve? Fix: Generate avatar; Add knowledge graph; Modify the style of the multi-select component [#3221](https://github.com/infiniflow/ragflow/issues/3221) ### Type of change - [x] Bug Fix (non-breaking change which fixes an issue)tags/v0.20.0
| import { cn } from '@/lib/utils'; | import { cn } from '@/lib/utils'; | ||||
| import * as AvatarPrimitive from '@radix-ui/react-avatar'; | import * as AvatarPrimitive from '@radix-ui/react-avatar'; | ||||
| import { random } from 'lodash'; | |||||
| import { forwardRef } from 'react'; | |||||
| import { forwardRef, memo, useEffect, useRef, useState } from 'react'; | |||||
| import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'; | import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'; | ||||
| const Colors = [ | |||||
| { from: '#4F6DEE', to: '#67BDF9' }, | |||||
| { from: '#38A04D', to: '#93DCA2' }, | |||||
| { from: '#EDB395', to: '#C35F2B' }, | |||||
| { from: '#633897', to: '#CBA1FF' }, | |||||
| ]; | |||||
| export const RAGFlowAvatar = forwardRef< | |||||
| React.ElementRef<typeof AvatarPrimitive.Root>, | |||||
| React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> & { | |||||
| name?: string; | |||||
| avatar?: string; | |||||
| isPerson?: boolean; | |||||
| const getStringHash = (str: string): number => { | |||||
| const normalized = str.trim().toLowerCase(); | |||||
| let hash = 104729; | |||||
| const seed = 0x9747b28c; | |||||
| for (let i = 0; i < normalized.length; i++) { | |||||
| hash ^= seed ^ normalized.charCodeAt(i); | |||||
| hash = (hash << 13) | (hash >>> 19); | |||||
| hash = (hash * 5 + 0x52dce72d) | 0; | |||||
| } | } | ||||
| >(({ name, avatar, isPerson = false, className, ...props }, ref) => { | |||||
| const index = random(0, 3); | |||||
| console.log('🚀 ~ index:', index); | |||||
| const value = Colors[index]; | |||||
| return ( | |||||
| <Avatar | |||||
| ref={ref} | |||||
| {...props} | |||||
| className={cn(className, { 'rounded-md': !isPerson })} | |||||
| > | |||||
| <AvatarImage src={avatar} /> | |||||
| <AvatarFallback | |||||
| className={cn( | |||||
| `bg-gradient-to-b from-[${value.from}] to-[${value.to}]`, | |||||
| { 'rounded-md': !isPerson }, | |||||
| )} | |||||
| return Math.abs(hash); | |||||
| }; | |||||
| // Generate a hash function with a fixed color | |||||
| const getColorForName = (name: string): { from: string; to: string } => { | |||||
| const hash = getStringHash(name); | |||||
| const hue = hash % 360; | |||||
| return { | |||||
| from: `hsl(${hue}, 70%, 80%)`, | |||||
| to: `hsl(${hue}, 60%, 30%)`, | |||||
| }; | |||||
| }; | |||||
| export const RAGFlowAvatar = memo( | |||||
| forwardRef< | |||||
| React.ElementRef<typeof AvatarPrimitive.Root>, | |||||
| React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> & { | |||||
| name?: string; | |||||
| avatar?: string; | |||||
| isPerson?: boolean; | |||||
| } | |||||
| >(({ name, avatar, isPerson = false, className, ...props }, ref) => { | |||||
| // Generate initial letter logic | |||||
| const getInitials = (name?: string) => { | |||||
| if (!name) return ''; | |||||
| const parts = name.trim().split(/\s+/); | |||||
| if (parts.length === 1) { | |||||
| return parts[0][0].toUpperCase(); | |||||
| } | |||||
| return parts[0][0].toUpperCase() + parts[1][0].toUpperCase(); | |||||
| }; | |||||
| const initials = getInitials(name); | |||||
| const { from, to } = name | |||||
| ? getColorForName(name) | |||||
| : { from: 'hsl(0, 0%, 80%)', to: 'hsl(0, 0%, 30%)' }; | |||||
| const fallbackRef = useRef<HTMLElement>(null); | |||||
| const [fontSize, setFontSize] = useState('0.875rem'); | |||||
| // Calculate font size | |||||
| const calculateFontSize = () => { | |||||
| if (fallbackRef.current) { | |||||
| const containerWidth = fallbackRef.current.offsetWidth; | |||||
| const newSize = containerWidth * 0.6; | |||||
| setFontSize(`${newSize}px`); | |||||
| } | |||||
| }; | |||||
| useEffect(() => { | |||||
| calculateFontSize(); | |||||
| if (fallbackRef.current) { | |||||
| const resizeObserver = new ResizeObserver(() => { | |||||
| calculateFontSize(); | |||||
| }); | |||||
| resizeObserver.observe(fallbackRef.current); | |||||
| return () => { | |||||
| if (fallbackRef.current) { | |||||
| resizeObserver.unobserve(fallbackRef.current); | |||||
| } | |||||
| resizeObserver.disconnect(); | |||||
| }; | |||||
| } | |||||
| }, []); | |||||
| return ( | |||||
| <Avatar | |||||
| ref={ref} | |||||
| {...props} | |||||
| className={cn(className, { 'rounded-md': !isPerson })} | |||||
| > | > | ||||
| {name?.slice(0, 1)} | |||||
| </AvatarFallback> | |||||
| </Avatar> | |||||
| ); | |||||
| }); | |||||
| <AvatarImage src={avatar} /> | |||||
| <AvatarFallback | |||||
| ref={(node) => { | |||||
| fallbackRef.current = node; | |||||
| calculateFontSize(); | |||||
| }} | |||||
| className={cn( | |||||
| 'bg-gradient-to-b', | |||||
| `from-[${from}] to-[${to}]`, | |||||
| 'flex items-center justify-center', | |||||
| 'text-white font-bold', | |||||
| { 'rounded-md': !isPerson }, | |||||
| )} | |||||
| style={{ | |||||
| backgroundImage: `linear-gradient(to bottom, ${from}, ${to})`, | |||||
| fontSize: fontSize, | |||||
| }} | |||||
| > | |||||
| {initials} | |||||
| </AvatarFallback> | |||||
| </Avatar> | |||||
| ); | |||||
| }), | |||||
| ); | |||||
| RAGFlowAvatar.displayName = 'RAGFlowAvatar'; | RAGFlowAvatar.displayName = 'RAGFlowAvatar'; |
| return ( | return ( | ||||
| <Badge | <Badge | ||||
| key={value} | key={value} | ||||
| variant="secondary" | |||||
| className={cn( | className={cn( | ||||
| isAnimating ? 'animate-bounce' : '', | isAnimating ? 'animate-bounce' : '', | ||||
| multiSelectVariants({ variant }), | multiSelectVariants({ variant }), | ||||
| )} | )} | ||||
| style={{ animationDuration: `${animation}s` }} | style={{ animationDuration: `${animation}s` }} | ||||
| > | > | ||||
| {IconComponent && ( | |||||
| <IconComponent className="h-4 w-4 mr-2" /> | |||||
| )} | |||||
| {option?.label} | |||||
| <XCircle | |||||
| className="ml-2 h-4 w-4 cursor-pointer" | |||||
| onClick={(event) => { | |||||
| event.stopPropagation(); | |||||
| toggleOption(value); | |||||
| }} | |||||
| /> | |||||
| <div className="flex items-center gap-1"> | |||||
| {IconComponent && ( | |||||
| <IconComponent className="h-4 w-4" /> | |||||
| )} | |||||
| <div>{option?.label}</div> | |||||
| <XCircle | |||||
| className="h-4 w-4 cursor-pointer" | |||||
| onClick={(event) => { | |||||
| event.stopPropagation(); | |||||
| toggleOption(value); | |||||
| }} | |||||
| /> | |||||
| </div> | |||||
| </Badge> | </Badge> | ||||
| ); | ); | ||||
| })} | })} |
| import { useHandleFilterSubmit } from '@/components/list-filter-bar/use-handle-filter-submit'; | import { useHandleFilterSubmit } from '@/components/list-filter-bar/use-handle-filter-submit'; | ||||
| import { | import { | ||||
| IKnowledge, | IKnowledge, | ||||
| IKnowledgeGraph, | |||||
| IKnowledgeResult, | IKnowledgeResult, | ||||
| INextTestingResult, | INextTestingResult, | ||||
| } from '@/interfaces/database/knowledge'; | } from '@/interfaces/database/knowledge'; | ||||
| import { ITestRetrievalRequestBody } from '@/interfaces/request/knowledge'; | import { ITestRetrievalRequestBody } from '@/interfaces/request/knowledge'; | ||||
| import i18n from '@/locales/config'; | import i18n from '@/locales/config'; | ||||
| import kbService, { listDataset } from '@/services/knowledge-service'; | |||||
| import kbService, { | |||||
| getKnowledgeGraph, | |||||
| listDataset, | |||||
| } from '@/services/knowledge-service'; | |||||
| import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; | import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; | ||||
| import { useDebounce } from 'ahooks'; | import { useDebounce } from 'ahooks'; | ||||
| import { message } from 'antd'; | import { message } from 'antd'; | ||||
| FetchKnowledgeDetail = 'fetchKnowledgeDetail', | FetchKnowledgeDetail = 'fetchKnowledgeDetail', | ||||
| } | } | ||||
| export const useKnowledgeBaseId = () => { | |||||
| export const useKnowledgeBaseId = (): string => { | |||||
| const { id } = useParams(); | const { id } = useParams(); | ||||
| return id; | |||||
| return (id as string) || ''; | |||||
| }; | }; | ||||
| export const useTestRetrieval = () => { | export const useTestRetrieval = () => { | ||||
| return { data, loading }; | return { data, loading }; | ||||
| }; | }; | ||||
| export function useFetchKnowledgeGraph() { | |||||
| const knowledgeBaseId = useKnowledgeBaseId(); | |||||
| const { data, isFetching: loading } = useQuery<IKnowledgeGraph>({ | |||||
| queryKey: ['fetchKnowledgeGraph', knowledgeBaseId], | |||||
| initialData: { graph: {}, mind_map: {} } as IKnowledgeGraph, | |||||
| enabled: !!knowledgeBaseId, | |||||
| gcTime: 0, | |||||
| queryFn: async () => { | |||||
| const { data } = await getKnowledgeGraph(knowledgeBaseId); | |||||
| return data?.data; | |||||
| }, | |||||
| }); | |||||
| return { data, loading }; | |||||
| } |
| const nodes = [ | |||||
| { | |||||
| type: '"ORGANIZATION"', | |||||
| description: | |||||
| '"厦门象屿是一家公司,其营业收入和市场占有率在2018年至2022年间有所变化。"', | |||||
| source_id: '0', | |||||
| id: '"厦门象屿"', | |||||
| }, | |||||
| { | |||||
| type: '"EVENT"', | |||||
| description: | |||||
| '"2018年是一个时间点,标志着厦门象屿营业收入和市场占有率的记录开始。"', | |||||
| source_id: '0', | |||||
| entity_type: '"EVENT"', | |||||
| id: '"2018"', | |||||
| }, | |||||
| { | |||||
| type: '"EVENT"', | |||||
| description: | |||||
| '"2019年是一个时间点,厦门象屿的营业收入和市场占有率在此期间有所变化。"', | |||||
| source_id: '0', | |||||
| entity_type: '"EVENT"', | |||||
| id: '"2019"', | |||||
| }, | |||||
| { | |||||
| type: '"EVENT"', | |||||
| description: | |||||
| '"2020年是一个时间点,厦门象屿的营业收入和市场占有率在此期间有所变化。"', | |||||
| source_id: '0', | |||||
| entity_type: '"EVENT"', | |||||
| id: '"2020"', | |||||
| }, | |||||
| { | |||||
| type: '"EVENT"', | |||||
| description: | |||||
| '"2021年是一个时间点,厦门象屿的营业收入和市场占有率在此期间有所变化。"', | |||||
| source_id: '0', | |||||
| entity_type: '"EVENT"', | |||||
| id: '"2021"', | |||||
| }, | |||||
| { | |||||
| type: '"EVENT"', | |||||
| description: | |||||
| '"2022年是一个时间点,厦门象屿的营业收入和市场占有率在此期间有所变化。"', | |||||
| source_id: '0', | |||||
| entity_type: '"EVENT"', | |||||
| id: '"2022"', | |||||
| }, | |||||
| { | |||||
| type: '"ORGANIZATION"', | |||||
| description: | |||||
| '"厦门象屿股份有限公司是一家公司,中文简称为厦门象屿,外文名称为Xiamen Xiangyu Co.,Ltd.,外文名称缩写为Xiangyu,法定代表人为邓启东。"', | |||||
| source_id: '1', | |||||
| id: '"厦门象屿股份有限公司"', | |||||
| }, | |||||
| { | |||||
| type: '"PERSON"', | |||||
| description: '"邓启东是厦门象屿股份有限公司的法定代表人。"', | |||||
| source_id: '1', | |||||
| entity_type: '"PERSON"', | |||||
| id: '"邓启东"', | |||||
| }, | |||||
| { | |||||
| type: '"GEO"', | |||||
| description: '"厦门是一个地理位置,与厦门象屿股份有限公司相关。"', | |||||
| source_id: '1', | |||||
| entity_type: '"GEO"', | |||||
| id: '"厦门"', | |||||
| }, | |||||
| { | |||||
| type: '"PERSON"', | |||||
| description: | |||||
| '"廖杰 is the Board Secretary, responsible for handling board-related matters and communications."', | |||||
| source_id: '2', | |||||
| id: '"廖杰"', | |||||
| }, | |||||
| { | |||||
| type: '"PERSON"', | |||||
| description: | |||||
| '"史经洋 is the Securities Affairs Representative, responsible for handling securities-related matters and communications."', | |||||
| source_id: '2', | |||||
| entity_type: '"PERSON"', | |||||
| id: '"史经洋"', | |||||
| }, | |||||
| { | |||||
| type: '"GEO"', | |||||
| description: | |||||
| '"A geographic location in Xiamen, specifically in the Free Trade Zone, where the company\'s office is situated."', | |||||
| source_id: '2', | |||||
| entity_type: '"GEO"', | |||||
| id: '"厦门市湖里区自由贸易试验区厦门片区"', | |||||
| }, | |||||
| { | |||||
| type: '"GEO"', | |||||
| description: | |||||
| '"The building where the company\'s office is located, situated at Xiangyu Road, Xiamen."', | |||||
| source_id: '2', | |||||
| entity_type: '"GEO"', | |||||
| id: '"象屿集团大厦"', | |||||
| }, | |||||
| { | |||||
| type: '"EVENT"', | |||||
| description: | |||||
| '"Refers to the year 2021, used for comparing financial metrics with the year 2022."', | |||||
| source_id: '3', | |||||
| id: '"2021年"', | |||||
| }, | |||||
| { | |||||
| type: '"EVENT"', | |||||
| description: | |||||
| '"Refers to the year 2022, used for presenting current financial metrics and comparing them with the year 2021."', | |||||
| source_id: '3', | |||||
| entity_type: '"EVENT"', | |||||
| id: '"2022年"', | |||||
| }, | |||||
| { | |||||
| type: '"EVENT"', | |||||
| description: | |||||
| '"Indicates the focus on key financial metrics in the table, such as weighted averages and percentages."', | |||||
| source_id: '3', | |||||
| entity_type: '"EVENT"', | |||||
| id: '"主要财务指标"', | |||||
| }, | |||||
| ].map(({ type, ...x }) => ({ ...x })); | |||||
| const edges = [ | |||||
| { | |||||
| weight: 2.0, | |||||
| description: '"厦门象屿在2018年的营业收入和市场占有率被记录。"', | |||||
| source_id: '0', | |||||
| source: '"厦门象屿"', | |||||
| target: '"2018"', | |||||
| }, | |||||
| { | |||||
| weight: 2.0, | |||||
| description: '"厦门象屿在2019年的营业收入和市场占有率有所变化。"', | |||||
| source_id: '0', | |||||
| source: '"厦门象屿"', | |||||
| target: '"2019"', | |||||
| }, | |||||
| { | |||||
| weight: 2.0, | |||||
| description: '"厦门象屿在2020年的营业收入和市场占有率有所变化。"', | |||||
| source_id: '0', | |||||
| source: '"厦门象屿"', | |||||
| target: '"2020"', | |||||
| }, | |||||
| { | |||||
| weight: 2.0, | |||||
| description: '"厦门象屿在2021年的营业收入和市场占有率有所变化。"', | |||||
| source_id: '0', | |||||
| source: '"厦门象屿"', | |||||
| target: '"2021"', | |||||
| }, | |||||
| { | |||||
| weight: 2.0, | |||||
| description: '"厦门象屿在2022年的营业收入和市场占有率有所变化。"', | |||||
| source_id: '0', | |||||
| source: '"厦门象屿"', | |||||
| target: '"2022"', | |||||
| }, | |||||
| { | |||||
| weight: 2.0, | |||||
| description: '"厦门象屿股份有限公司的法定代表人是邓启东。"', | |||||
| source_id: '1', | |||||
| source: '"厦门象屿股份有限公司"', | |||||
| target: '"邓启东"', | |||||
| }, | |||||
| { | |||||
| weight: 2.0, | |||||
| description: '"厦门象屿股份有限公司位于厦门。"', | |||||
| source_id: '1', | |||||
| source: '"厦门象屿股份有限公司"', | |||||
| target: '"厦门"', | |||||
| }, | |||||
| { | |||||
| weight: 2.0, | |||||
| description: | |||||
| '"廖杰\'s office is located in the Xiangyu Group Building, indicating his workplace."', | |||||
| source_id: '2', | |||||
| source: '"廖杰"', | |||||
| target: '"象屿集团大厦"', | |||||
| }, | |||||
| { | |||||
| weight: 2.0, | |||||
| description: | |||||
| '"廖杰 works in the Xiamen Free Trade Zone, a specific area within Xiamen."', | |||||
| source_id: '2', | |||||
| source: '"廖杰"', | |||||
| target: '"厦门市湖里区自由贸易试验区厦门片区"', | |||||
| }, | |||||
| { | |||||
| weight: 2.0, | |||||
| description: | |||||
| '"史经洋\'s office is also located in the Xiangyu Group Building, indicating his workplace."', | |||||
| source_id: '2', | |||||
| source: '"史经洋"', | |||||
| target: '"象屿集团大厦"', | |||||
| }, | |||||
| { | |||||
| weight: 2.0, | |||||
| description: | |||||
| '"史经洋 works in the Xiamen Free Trade Zone, a specific area within Xiamen."', | |||||
| source_id: '2', | |||||
| source: '"史经洋"', | |||||
| target: '"厦门市湖里区自由贸易试验区厦门片区"', | |||||
| }, | |||||
| { | |||||
| weight: 2.0, | |||||
| description: | |||||
| '"The years 2021 and 2022 are related as they are used for comparing financial metrics, showing changes and adjustments over time."', | |||||
| source_id: '3', | |||||
| source: '"2021年"', | |||||
| target: '"2022年"', | |||||
| }, | |||||
| { | |||||
| weight: 2.0, | |||||
| description: | |||||
| '"The \'主要财务指标\' is related to the year 2021 as it provides the basis for financial comparisons and adjustments."', | |||||
| source_id: '3', | |||||
| source: '"2021年"', | |||||
| target: '"主要财务指标"', | |||||
| }, | |||||
| { | |||||
| weight: 2.0, | |||||
| description: | |||||
| '"The \'主要财务指标\' is related to the year 2022 as it presents the current financial metrics and their changes compared to 2021."', | |||||
| source_id: '3', | |||||
| source: '"2022年"', | |||||
| target: '"主要财务指标"', | |||||
| }, | |||||
| ]; | |||||
| export const graphData = { | |||||
| directed: false, | |||||
| multigraph: false, | |||||
| graph: {}, | |||||
| nodes, | |||||
| edges, | |||||
| combos: [], | |||||
| }; |
| import { ElementDatum, Graph, IElementEvent } from '@antv/g6'; | |||||
| import isEmpty from 'lodash/isEmpty'; | |||||
| import { useCallback, useEffect, useMemo, useRef } from 'react'; | |||||
| import { buildNodesAndCombos } from './util'; | |||||
| import styles from './index.less'; | |||||
| const TooltipColorMap = { | |||||
| combo: 'red', | |||||
| node: 'black', | |||||
| edge: 'blue', | |||||
| }; | |||||
| interface IProps { | |||||
| data: any; | |||||
| show: boolean; | |||||
| } | |||||
| const ForceGraph = ({ data, show }: IProps) => { | |||||
| const containerRef = useRef<HTMLDivElement>(null); | |||||
| const graphRef = useRef<Graph | null>(null); | |||||
| const nextData = useMemo(() => { | |||||
| if (!isEmpty(data)) { | |||||
| const graphData = data; | |||||
| const mi = buildNodesAndCombos(graphData.nodes); | |||||
| return { edges: graphData.edges, ...mi }; | |||||
| } | |||||
| return { nodes: [], edges: [] }; | |||||
| }, [data]); | |||||
| const render = useCallback(() => { | |||||
| const graph = new Graph({ | |||||
| container: containerRef.current!, | |||||
| autoFit: 'view', | |||||
| autoResize: true, | |||||
| behaviors: [ | |||||
| 'drag-element', | |||||
| 'drag-canvas', | |||||
| 'zoom-canvas', | |||||
| 'collapse-expand', | |||||
| { | |||||
| type: 'hover-activate', | |||||
| degree: 1, // 👈🏻 Activate relations. | |||||
| }, | |||||
| ], | |||||
| plugins: [ | |||||
| { | |||||
| type: 'tooltip', | |||||
| enterable: true, | |||||
| getContent: (e: IElementEvent, items: ElementDatum) => { | |||||
| if (Array.isArray(items)) { | |||||
| if (items.some((x) => x?.isCombo)) { | |||||
| return `<p style="font-weight:600;color:red">${items?.[0]?.data?.label}</p>`; | |||||
| } | |||||
| let result = ``; | |||||
| items.forEach((item) => { | |||||
| result += `<section style="color:${TooltipColorMap[e['targetType'] as keyof typeof TooltipColorMap]};"><h3>${item?.id}</h3>`; | |||||
| if (item?.entity_type) { | |||||
| result += `<div style="padding-bottom: 6px;"><b>Entity type: </b>${item?.entity_type}</div>`; | |||||
| } | |||||
| if (item?.weight) { | |||||
| result += `<div><b>Weight: </b>${item?.weight}</div>`; | |||||
| } | |||||
| if (item?.description) { | |||||
| result += `<p>${item?.description}</p>`; | |||||
| } | |||||
| }); | |||||
| return result + '</section>'; | |||||
| } | |||||
| return undefined; | |||||
| }, | |||||
| }, | |||||
| ], | |||||
| layout: { | |||||
| type: 'combo-combined', | |||||
| preventOverlap: true, | |||||
| comboPadding: 1, | |||||
| spacing: 100, | |||||
| }, | |||||
| node: { | |||||
| style: { | |||||
| size: 150, | |||||
| labelText: (d) => d.id, | |||||
| // labelPadding: 30, | |||||
| labelFontSize: 40, | |||||
| // labelOffsetX: 20, | |||||
| labelOffsetY: 20, | |||||
| labelPlacement: 'center', | |||||
| labelWordWrap: true, | |||||
| }, | |||||
| palette: { | |||||
| type: 'group', | |||||
| field: (d) => { | |||||
| return d?.entity_type as string; | |||||
| }, | |||||
| }, | |||||
| }, | |||||
| edge: { | |||||
| style: (model) => { | |||||
| const weight: number = Number(model?.weight) || 2; | |||||
| const lineWeight = weight * 4; | |||||
| return { | |||||
| stroke: '#99ADD1', | |||||
| lineWidth: lineWeight > 10 ? 10 : lineWeight, | |||||
| }; | |||||
| }, | |||||
| }, | |||||
| }); | |||||
| if (graphRef.current) { | |||||
| graphRef.current.destroy(); | |||||
| } | |||||
| graphRef.current = graph; | |||||
| graph.setData(nextData); | |||||
| graph.render(); | |||||
| }, [nextData]); | |||||
| useEffect(() => { | |||||
| if (!isEmpty(data)) { | |||||
| render(); | |||||
| } | |||||
| }, [data, render]); | |||||
| return ( | |||||
| <div | |||||
| ref={containerRef} | |||||
| className={styles.forceContainer} | |||||
| style={{ | |||||
| width: '100%', | |||||
| height: '100%', | |||||
| display: show ? 'block' : 'none', | |||||
| }} | |||||
| /> | |||||
| ); | |||||
| }; | |||||
| export default ForceGraph; |
| .forceContainer { | |||||
| :global(.tooltip) { | |||||
| border-radius: 10px !important; | |||||
| } | |||||
| } |
| import { ConfirmDeleteDialog } from '@/components/confirm-delete-dialog'; | |||||
| import { Button } from '@/components/ui/button'; | |||||
| import { useFetchKnowledgeGraph } from '@/hooks/knowledge-hooks'; | |||||
| import { Trash2 } from 'lucide-react'; | |||||
| import React from 'react'; | |||||
| import { useTranslation } from 'react-i18next'; | |||||
| import ForceGraph from './force-graph'; | |||||
| import { useDeleteKnowledgeGraph } from './use-delete-graph'; | |||||
| const KnowledgeGraph: React.FC = () => { | |||||
| const { data } = useFetchKnowledgeGraph(); | |||||
| const { t } = useTranslation(); | |||||
| const { handleDeleteKnowledgeGraph } = useDeleteKnowledgeGraph(); | |||||
| return ( | |||||
| <section className={'w-full h-[90dvh] relative p-6'}> | |||||
| <ConfirmDeleteDialog onOk={handleDeleteKnowledgeGraph}> | |||||
| <Button | |||||
| variant="outline" | |||||
| size={'sm'} | |||||
| className="absolute right-0 top-0 z-50" | |||||
| > | |||||
| <Trash2 /> {t('common.delete')} | |||||
| </Button> | |||||
| </ConfirmDeleteDialog> | |||||
| <ForceGraph data={data?.graph} show></ForceGraph> | |||||
| </section> | |||||
| ); | |||||
| }; | |||||
| export default KnowledgeGraph; |
| import { | |||||
| useKnowledgeBaseId, | |||||
| useRemoveKnowledgeGraph, | |||||
| } from '@/hooks/knowledge-hooks'; | |||||
| import { useCallback } from 'react'; | |||||
| import { useNavigate } from 'umi'; | |||||
| export function useDeleteKnowledgeGraph() { | |||||
| const { removeKnowledgeGraph, loading } = useRemoveKnowledgeGraph(); | |||||
| const navigate = useNavigate(); | |||||
| const knowledgeBaseId = useKnowledgeBaseId(); | |||||
| const handleDeleteKnowledgeGraph = useCallback(async () => { | |||||
| const ret = await removeKnowledgeGraph(); | |||||
| if (ret === 0) { | |||||
| navigate(`/knowledge/dataset?id=${knowledgeBaseId}`); | |||||
| } | |||||
| }, [knowledgeBaseId, navigate, removeKnowledgeGraph]); | |||||
| return { handleDeleteKnowledgeGraph, loading }; | |||||
| } |
| import { isEmpty } from 'lodash'; | |||||
| import { v4 as uuid } from 'uuid'; | |||||
| class KeyGenerator { | |||||
| idx = 0; | |||||
| chars: string[] = []; | |||||
| constructor() { | |||||
| const chars = Array(26) | |||||
| .fill(1) | |||||
| .map((x, idx) => String.fromCharCode(97 + idx)); // 26 char | |||||
| this.chars = chars; | |||||
| } | |||||
| generateKey() { | |||||
| const key = this.chars[this.idx]; | |||||
| this.idx++; | |||||
| return key; | |||||
| } | |||||
| } | |||||
| // Classify nodes based on edge relationships | |||||
| export class Converter { | |||||
| keyGenerator; | |||||
| dict: Record<string, string> = {}; // key is node id, value is combo | |||||
| constructor() { | |||||
| this.keyGenerator = new KeyGenerator(); | |||||
| } | |||||
| buildDict(edges: { source: string; target: string }[]) { | |||||
| edges.forEach((x) => { | |||||
| if (this.dict[x.source] && !this.dict[x.target]) { | |||||
| this.dict[x.target] = this.dict[x.source]; | |||||
| } else if (!this.dict[x.source] && this.dict[x.target]) { | |||||
| this.dict[x.source] = this.dict[x.target]; | |||||
| } else if (!this.dict[x.source] && !this.dict[x.target]) { | |||||
| this.dict[x.source] = this.dict[x.target] = | |||||
| this.keyGenerator.generateKey(); | |||||
| } | |||||
| }); | |||||
| return this.dict; | |||||
| } | |||||
| buildNodesAndCombos(nodes: any[], edges: any[]) { | |||||
| this.buildDict(edges); | |||||
| const nextNodes = nodes.map((x) => ({ ...x, combo: this.dict[x.id] })); | |||||
| const combos = Object.values(this.dict).reduce<any[]>((pre, cur) => { | |||||
| if (pre.every((x) => x.id !== cur)) { | |||||
| pre.push({ | |||||
| id: cur, | |||||
| data: { | |||||
| label: `Combo ${cur}`, | |||||
| }, | |||||
| }); | |||||
| } | |||||
| return pre; | |||||
| }, []); | |||||
| return { nodes: nextNodes, combos }; | |||||
| } | |||||
| } | |||||
| export const isDataExist = (data: any) => { | |||||
| return ( | |||||
| data?.data && typeof data?.data !== 'boolean' && !isEmpty(data?.data?.graph) | |||||
| ); | |||||
| }; | |||||
| const findCombo = (communities: string[]) => { | |||||
| const combo = Array.isArray(communities) ? communities[0] : undefined; | |||||
| return combo; | |||||
| }; | |||||
| export const buildNodesAndCombos = (nodes: any[]) => { | |||||
| const combos: any[] = []; | |||||
| nodes.forEach((x) => { | |||||
| const combo = findCombo(x?.communities); | |||||
| if (combo && combos.every((y) => y.data.label !== combo)) { | |||||
| combos.push({ | |||||
| isCombo: true, | |||||
| id: uuid(), | |||||
| data: { | |||||
| label: combo, | |||||
| }, | |||||
| }); | |||||
| } | |||||
| }); | |||||
| const nextNodes = nodes.map((x) => { | |||||
| return { | |||||
| ...x, | |||||
| combo: combos.find((y) => y.data.label === findCombo(x?.communities))?.id, | |||||
| }; | |||||
| }); | |||||
| return { nodes: nextNodes, combos }; | |||||
| }; |
| import { RAGFlowAvatar } from '@/components/ragflow-avatar'; | |||||
| import { SliderInputFormField } from '@/components/slider-input-form-field'; | import { SliderInputFormField } from '@/components/slider-input-form-field'; | ||||
| import { | import { | ||||
| FormControl, | FormControl, | ||||
| } from '@/components/ui/form'; | } from '@/components/ui/form'; | ||||
| import { MultiSelect } from '@/components/ui/multi-select'; | import { MultiSelect } from '@/components/ui/multi-select'; | ||||
| import { useFetchKnowledgeList } from '@/hooks/knowledge-hooks'; | import { useFetchKnowledgeList } from '@/hooks/knowledge-hooks'; | ||||
| import { UserOutlined } from '@ant-design/icons'; | |||||
| import { Avatar, Flex, Form, InputNumber, Select, Slider, Space } from 'antd'; | |||||
| import { Flex, Form, InputNumber, Select, Slider, Space } from 'antd'; | |||||
| import DOMPurify from 'dompurify'; | import DOMPurify from 'dompurify'; | ||||
| import { useFormContext, useWatch } from 'react-hook-form'; | import { useFormContext, useWatch } from 'react-hook-form'; | ||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| value: x.id, | value: x.id, | ||||
| icon: () => ( | icon: () => ( | ||||
| <Space> | <Space> | ||||
| <Avatar size={20} icon={<UserOutlined />} src={x.avatar} /> | |||||
| {x.name} | |||||
| <RAGFlowAvatar | |||||
| name={x.name} | |||||
| avatar={x.avatar} | |||||
| className="size-4" | |||||
| ></RAGFlowAvatar> | |||||
| </Space> | </Space> | ||||
| ), | ), | ||||
| })); | })); | ||||
| onValueChange={field.onChange} | onValueChange={field.onChange} | ||||
| placeholder={t('chat.knowledgeBasesMessage')} | placeholder={t('chat.knowledgeBasesMessage')} | ||||
| variant="inverted" | variant="inverted" | ||||
| maxCount={0} | |||||
| maxCount={10} | |||||
| {...field} | {...field} | ||||
| /> | /> | ||||
| </FormControl> | </FormControl> |
| import { RAGFlowAvatar } from '@/components/ragflow-avatar'; | import { RAGFlowAvatar } from '@/components/ragflow-avatar'; | ||||
| import { Button } from '@/components/ui/button'; | import { Button } from '@/components/ui/button'; | ||||
| import { useSecondPathName } from '@/hooks/route-hook'; | import { useSecondPathName } from '@/hooks/route-hook'; | ||||
| import { useFetchKnowledgeBaseConfiguration } from '@/hooks/use-knowledge-request'; | |||||
| import { | |||||
| useFetchKnowledgeBaseConfiguration, | |||||
| useFetchKnowledgeGraph, | |||||
| } from '@/hooks/use-knowledge-request'; | |||||
| import { cn, formatBytes } from '@/lib/utils'; | import { cn, formatBytes } from '@/lib/utils'; | ||||
| import { Routes } from '@/routes'; | import { Routes } from '@/routes'; | ||||
| import { formatPureDate } from '@/utils/date'; | import { formatPureDate } from '@/utils/date'; | ||||
| import { Banknote, Database, FileSearch2 } from 'lucide-react'; | |||||
| import { isEmpty } from 'lodash'; | |||||
| import { Banknote, Database, FileSearch2, GitGraph } from 'lucide-react'; | |||||
| import { useMemo } from 'react'; | import { useMemo } from 'react'; | ||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import { useHandleMenuClick } from './hooks'; | import { useHandleMenuClick } from './hooks'; | ||||
| const { handleMenuClick } = useHandleMenuClick(); | const { handleMenuClick } = useHandleMenuClick(); | ||||
| // refreshCount: be for avatar img sync update on top left | // refreshCount: be for avatar img sync update on top left | ||||
| const { data } = useFetchKnowledgeBaseConfiguration(refreshCount); | const { data } = useFetchKnowledgeBaseConfiguration(refreshCount); | ||||
| const { data: routerData } = useFetchKnowledgeGraph(); | |||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const items = useMemo(() => { | const items = useMemo(() => { | ||||
| return [ | |||||
| const list = [ | |||||
| { | { | ||||
| icon: Database, | icon: Database, | ||||
| label: t(`knowledgeDetails.dataset`), | label: t(`knowledgeDetails.dataset`), | ||||
| key: Routes.DatasetSetting, | key: Routes.DatasetSetting, | ||||
| }, | }, | ||||
| ]; | ]; | ||||
| }, [t]); | |||||
| if (!isEmpty(routerData?.graph)) { | |||||
| list.push({ | |||||
| icon: GitGraph, | |||||
| label: t(`knowledgeDetails.knowledgeGraph`), | |||||
| key: Routes.KnowledgeGraph, | |||||
| }); | |||||
| } | |||||
| return list; | |||||
| }, [t, routerData]); | |||||
| return ( | return ( | ||||
| <aside className="relative p-5 space-y-8"> | <aside className="relative p-5 space-y-8"> |
| ParsedResult = `${Chunk}${Parsed}`, | ParsedResult = `${Chunk}${Parsed}`, | ||||
| Result = '/result', | Result = '/result', | ||||
| ResultView = `${Chunk}${Result}`, | ResultView = `${Chunk}${Result}`, | ||||
| KnowledgeGraph = '/knowledge-graph', | |||||
| } | } | ||||
| const routes = [ | const routes = [ | ||||
| path: `${Routes.DatasetBase}${Routes.DatasetTesting}/:id`, | path: `${Routes.DatasetBase}${Routes.DatasetTesting}/:id`, | ||||
| component: `@/pages${Routes.DatasetBase}${Routes.DatasetTesting}`, | component: `@/pages${Routes.DatasetBase}${Routes.DatasetTesting}`, | ||||
| }, | }, | ||||
| { | |||||
| path: `${Routes.DatasetBase}${Routes.KnowledgeGraph}/:id`, | |||||
| component: `@/pages${Routes.DatasetBase}${Routes.KnowledgeGraph}`, | |||||
| }, | |||||
| ], | ], | ||||
| }, | }, | ||||
| { | { |