| @@ -24,7 +24,7 @@ const DemoForm = () => { | |||
| }, | |||
| }) | |||
| const name = useStore(form.store, state => state.values.name) | |||
| const name = useStore(form.store, state => state.values.name) | |||
| return ( | |||
| <form | |||
| @@ -59,7 +59,7 @@ const name = useStore(form.store, state => state.values.name) | |||
| ) | |||
| } | |||
| <form.AppForm> | |||
| <form.SubmitButton>Submit</form.SubmitButton> | |||
| <form.Actions /> | |||
| </form.AppForm> | |||
| </form> | |||
| ) | |||
| @@ -0,0 +1,62 @@ | |||
| import { useCallback, useEffect } from 'react' | |||
| import { useDatasourceOptions } from '../hooks' | |||
| import OptionCard from './option-card' | |||
| import { File, Watercrawl } from '@/app/components/base/icons/src/public/knowledge' | |||
| import { Notion } from '@/app/components/base/icons/src/public/common' | |||
| import { Jina } from '@/app/components/base/icons/src/public/llm' | |||
| import { DataSourceType } from '@/models/datasets' | |||
| import { DataSourceProvider } from '@/models/common' | |||
| import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types' | |||
| import type { Node } from '@/app/components/workflow/types' | |||
| import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' | |||
| type DataSourceOptionsProps = { | |||
| pipelineNodes: Node<DataSourceNodeType>[] | |||
| datasourceNodeId: string | |||
| onSelect: (option: Datasource) => void | |||
| } | |||
| const DATA_SOURCE_ICONS = { | |||
| [DataSourceType.FILE]: File as React.FC<React.SVGProps<SVGSVGElement>>, | |||
| [DataSourceType.NOTION]: Notion as React.FC<React.SVGProps<SVGSVGElement>>, | |||
| [DataSourceProvider.fireCrawl]: '🔥', | |||
| [DataSourceProvider.jinaReader]: Jina as React.FC<React.SVGProps<SVGSVGElement>>, | |||
| [DataSourceProvider.waterCrawl]: Watercrawl as React.FC<React.SVGProps<SVGSVGElement>>, | |||
| } | |||
| const DataSourceOptions = ({ | |||
| pipelineNodes, | |||
| datasourceNodeId, | |||
| onSelect, | |||
| }: DataSourceOptionsProps) => { | |||
| const { datasources, options } = useDatasourceOptions(pipelineNodes) | |||
| const handelSelect = useCallback((value: string) => { | |||
| const selectedOption = datasources.find(option => option.nodeId === value) | |||
| if (!selectedOption) | |||
| return | |||
| onSelect(selectedOption) | |||
| }, [datasources, onSelect]) | |||
| useEffect(() => { | |||
| if (options.length > 0) | |||
| handelSelect(options[0].value) | |||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||
| }, []) | |||
| return ( | |||
| <div className='grid w-full grid-cols-4 gap-1'> | |||
| {options.map(option => ( | |||
| <OptionCard | |||
| key={option.value} | |||
| label={option.label} | |||
| selected={datasourceNodeId === option.value} | |||
| Icon={DATA_SOURCE_ICONS[option.type as keyof typeof DATA_SOURCE_ICONS]} | |||
| onClick={handelSelect.bind(null, option.value)} | |||
| /> | |||
| ))} | |||
| </div> | |||
| ) | |||
| } | |||
| export default DataSourceOptions | |||
| @@ -0,0 +1,41 @@ | |||
| import React from 'react' | |||
| import cn from '@/utils/classnames' | |||
| type OptionCardProps = { | |||
| label: string | |||
| Icon: React.FC<React.SVGProps<SVGSVGElement>> | string | |||
| selected: boolean | |||
| onClick?: () => void | |||
| } | |||
| const OptionCard = ({ | |||
| label, | |||
| Icon, | |||
| selected, | |||
| onClick, | |||
| }: OptionCardProps) => { | |||
| return ( | |||
| <div | |||
| className={cn( | |||
| 'flex items-center gap-2 rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg p-3 shadow-shadow-shadow-3', | |||
| selected | |||
| ? 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-xs ring-[0.5px] ring-inset ring-components-option-card-option-selected-border' | |||
| : 'hover:bg-components-option-card-bg-hover hover:border-components-option-card-option-border-hover hover:shadow-xs', | |||
| )} | |||
| onClick={onClick} | |||
| > | |||
| <div className='flex size-8 items-center justify-center rounded-lg border-[0.5px] border-components-panel-border bg-background-default-dodge p-1.5'> | |||
| { | |||
| typeof Icon === 'string' | |||
| ? <div className='text-[18px] leading-[18px]'>{Icon}</div> | |||
| : <Icon className='size-5' /> | |||
| } | |||
| </div> | |||
| <div className={cn('system-sm-medium text-text-secondary', selected && 'text-primary')}> | |||
| {label} | |||
| </div> | |||
| </div> | |||
| ) | |||
| } | |||
| export default React.memo(OptionCard) | |||
| @@ -0,0 +1,44 @@ | |||
| import React from 'react' | |||
| import Button from '@/app/components/base/button' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { useParams } from 'next/navigation' | |||
| import { RiArrowRightLine } from '@remixicon/react' | |||
| type ActionsProps = { | |||
| disabled?: boolean | |||
| handleNextStep: () => void | |||
| } | |||
| const Actions = ({ | |||
| disabled, | |||
| handleNextStep, | |||
| }: ActionsProps) => { | |||
| const { t } = useTranslation() | |||
| const { datasetId } = useParams() | |||
| return ( | |||
| <div className='flex justify-end gap-x-2'> | |||
| <a | |||
| href={`/datasets/${datasetId}/documents`} | |||
| > | |||
| <Button | |||
| variant='ghost' | |||
| className='px-3 py-2' | |||
| > | |||
| {t('common.operation.cancel')} | |||
| </Button> | |||
| </a> | |||
| <Button | |||
| disabled={disabled} | |||
| variant='primary' | |||
| onClick={handleNextStep} | |||
| className='gap-x-0.5' | |||
| > | |||
| <span className='px-0.5'>{t('datasetCreation.stepOne.button')}</span> | |||
| <RiArrowRightLine className='size-4' /> | |||
| </Button> | |||
| </div> | |||
| ) | |||
| } | |||
| export default React.memo(Actions) | |||
| @@ -1,5 +1,11 @@ | |||
| import { useTranslation } from 'react-i18next' | |||
| import { AddDocumentsStep } from './types' | |||
| import type { DataSourceOption, Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types' | |||
| import { useMemo } from 'react' | |||
| import { BlockEnum, type Node } from '@/app/components/workflow/types' | |||
| import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' | |||
| import { DataSourceType } from '@/models/datasets' | |||
| import { DataSourceProvider } from '@/models/common' | |||
| export const useAddDocumentsSteps = () => { | |||
| const { t } = useTranslation() | |||
| @@ -19,3 +25,79 @@ export const useAddDocumentsSteps = () => { | |||
| ] | |||
| return steps | |||
| } | |||
| export const useDatasourceOptions = (pipelineNodes: Node<DataSourceNodeType>[]) => { | |||
| const { t } = useTranslation() | |||
| const datasources: Datasource[] = useMemo(() => { | |||
| const datasourceNodes = pipelineNodes.filter(node => node.data.type === BlockEnum.DataSource) | |||
| return datasourceNodes.map((node) => { | |||
| let type: DataSourceType | DataSourceProvider = DataSourceType.FILE | |||
| switch (node.data.tool_name) { | |||
| case 'file_upload': | |||
| type = DataSourceType.FILE | |||
| break | |||
| case 'search_notion': | |||
| type = DataSourceType.NOTION | |||
| break | |||
| case 'firecrawl': | |||
| type = DataSourceProvider.fireCrawl | |||
| break | |||
| case 'jina_reader': | |||
| type = DataSourceProvider.jinaReader | |||
| break | |||
| case 'water_crawl': | |||
| type = DataSourceProvider.waterCrawl | |||
| break | |||
| } | |||
| return { | |||
| nodeId: node.id, | |||
| type, | |||
| variables: node.data.variables, | |||
| } | |||
| }) | |||
| }, [pipelineNodes]) | |||
| const options = useMemo(() => { | |||
| const options: DataSourceOption[] = [] | |||
| datasources.forEach((source) => { | |||
| if (source.type === DataSourceType.FILE) { | |||
| options.push({ | |||
| label: t('datasetPipeline.testRun.dataSource.localFiles'), | |||
| value: source.nodeId, | |||
| type: DataSourceType.FILE, | |||
| }) | |||
| } | |||
| if (source.type === DataSourceType.NOTION) { | |||
| options.push({ | |||
| label: 'Notion', | |||
| value: source.nodeId, | |||
| type: DataSourceType.NOTION, | |||
| }) | |||
| } | |||
| if (source.type === DataSourceProvider.fireCrawl) { | |||
| options.push({ | |||
| label: 'Firecrawl', | |||
| value: source.nodeId, | |||
| type: DataSourceProvider.fireCrawl, | |||
| }) | |||
| } | |||
| if (source.type === DataSourceProvider.jinaReader) { | |||
| options.push({ | |||
| label: 'Jina Reader', | |||
| value: source.nodeId, | |||
| type: DataSourceProvider.jinaReader, | |||
| }) | |||
| } | |||
| if (source.type === DataSourceProvider.waterCrawl) { | |||
| options.push({ | |||
| label: 'Water Crawl', | |||
| value: source.nodeId, | |||
| type: DataSourceProvider.waterCrawl, | |||
| }) | |||
| } | |||
| }) | |||
| return options | |||
| }, [datasources, t]) | |||
| return { datasources, options } | |||
| } | |||
| @@ -1,33 +1,30 @@ | |||
| 'use client' | |||
| import { useCallback, useMemo, useState } from 'react' | |||
| // import StepIndicator from './step-indicator' | |||
| // import { useTestRunSteps } from './hooks' | |||
| // import DataSourceOptions from './data-source-options' | |||
| import type { CrawlResultItem, FileItem } from '@/models/datasets' | |||
| import DataSourceOptions from './data-source-options' | |||
| import type { CrawlResultItem, CustomFile as File, FileItem } from '@/models/datasets' | |||
| import { DataSourceType } from '@/models/datasets' | |||
| // import LocalFile from './data-source/local-file' | |||
| import LocalFile from '@/app/components/rag-pipeline/components/panel/test-run/data-source/local-file' | |||
| import produce from 'immer' | |||
| import { useProviderContextSelector } from '@/context/provider-context' | |||
| import { DataSourceProvider, type NotionPage } from '@/models/common' | |||
| // import Notion from './data-source/notion' | |||
| import VectorSpaceFull from '@/app/components/billing/vector-space-full' | |||
| // import Firecrawl from './data-source/website/firecrawl' | |||
| // import JinaReader from './data-source/website/jina-reader' | |||
| // import WaterCrawl from './data-source/website/water-crawl' | |||
| // import Actions from './data-source/actions' | |||
| // import DocumentProcessing from './document-processing' | |||
| import { useTranslation } from 'react-i18next' | |||
| import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types' | |||
| import LocalFile from '@/app/components/rag-pipeline/components/panel/test-run/data-source/local-file' | |||
| import Notion from '@/app/components/rag-pipeline/components/panel/test-run/data-source/notion' | |||
| import VectorSpaceFull from '@/app/components/billing/vector-space-full' | |||
| import FireCrawl from '@/app/components/rag-pipeline/components/panel/test-run/data-source/website/firecrawl' | |||
| import JinaReader from '@/app/components/rag-pipeline/components/panel/test-run/data-source/website/jina-reader' | |||
| import WaterCrawl from '@/app/components/rag-pipeline/components/panel/test-run/data-source/website/water-crawl' | |||
| import Actions from '@/app/components/rag-pipeline/components/panel/test-run/data-source/actions' | |||
| import Actions from './data-source/actions' | |||
| import DocumentProcessing from '@/app/components/rag-pipeline/components/panel/test-run/document-processing' | |||
| import { useTranslation } from 'react-i18next' | |||
| import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types' | |||
| import LeftHeader from './left-header' | |||
| // import { usePipelineRun } from '../../../hooks' | |||
| // import type { Datasource } from './types' | |||
| import { usePublishedPipelineInfo } from '@/service/use-pipeline' | |||
| import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' | |||
| import Loading from '@/app/components/base/loading' | |||
| import type { Node } from '@/app/components/workflow/types' | |||
| import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' | |||
| import FilePreview from './preview/file-preview' | |||
| import NotionPagePreview from './preview/notion-page-preview' | |||
| import WebsitePreview from './preview/web-preview' | |||
| const TestRunPanel = () => { | |||
| const { t } = useTranslation() | |||
| @@ -37,11 +34,15 @@ const TestRunPanel = () => { | |||
| const [notionPages, setNotionPages] = useState<NotionPage[]>([]) | |||
| const [websitePages, setWebsitePages] = useState<CrawlResultItem[]>([]) | |||
| const [websiteCrawlJobId, setWebsiteCrawlJobId] = useState('') | |||
| const [currentFile, setCurrentFile] = useState<File | undefined>() | |||
| const [currentNotionPage, setCurrentNotionPage] = useState<NotionPage | undefined>() | |||
| const [currentWebsite, setCurrentWebsite] = useState<CrawlResultItem | undefined>() | |||
| const plan = useProviderContextSelector(state => state.plan) | |||
| const enableBilling = useProviderContextSelector(state => state.enableBilling) | |||
| const pipelineId = useDatasetDetailContextWithSelector(s => s.dataset?.pipeline_id) | |||
| // const steps = useTestRunSteps() | |||
| const { data: pipelineInfo, isFetching: isFetchingPipelineInfo } = usePublishedPipelineInfo(pipelineId || '') | |||
| const allFileLoaded = (fileList.length > 0 && fileList.every(file => file.file.id)) | |||
| const isVectorSpaceFull = plan.usage.vectorSpace >= plan.total.vectorSpace | |||
| @@ -79,13 +80,37 @@ const TestRunPanel = () => { | |||
| setFiles(newList) | |||
| } | |||
| const updateFileList = (preparedFiles: FileItem[]) => { | |||
| const updateFileList = useCallback((preparedFiles: FileItem[]) => { | |||
| setFiles(preparedFiles) | |||
| } | |||
| }, []) | |||
| const updateNotionPages = (value: NotionPage[]) => { | |||
| const updateNotionPages = useCallback((value: NotionPage[]) => { | |||
| setNotionPages(value) | |||
| } | |||
| }, []) | |||
| const updateCurrentFile = useCallback((file: File) => { | |||
| setCurrentFile(file) | |||
| }, []) | |||
| const hideFilePreview = useCallback(() => { | |||
| setCurrentFile(undefined) | |||
| }, []) | |||
| const updateCurrentPage = useCallback((page: NotionPage) => { | |||
| setCurrentNotionPage(page) | |||
| }, []) | |||
| const hideNotionPagePreview = useCallback(() => { | |||
| setCurrentNotionPage(undefined) | |||
| }, []) | |||
| const updateCurrentWebsite = useCallback((website: CrawlResultItem) => { | |||
| setCurrentWebsite(website) | |||
| }, []) | |||
| const hideWebsitePreview = useCallback(() => { | |||
| setCurrentWebsite(undefined) | |||
| }, []) | |||
| const handleNextStep = useCallback(() => { | |||
| setCurrentStep(preStep => preStep + 1) | |||
| @@ -95,8 +120,6 @@ const TestRunPanel = () => { | |||
| setCurrentStep(preStep => preStep - 1) | |||
| }, []) | |||
| // const { handleRun } = usePipelineRun() | |||
| const handleProcess = useCallback((data: Record<string, any>) => { | |||
| if (!datasource) | |||
| return | |||
| @@ -121,13 +144,16 @@ const TestRunPanel = () => { | |||
| datasourceInfo.jobId = websiteCrawlJobId | |||
| datasourceInfo.result = websitePages | |||
| } | |||
| // handleRun({ | |||
| // inputs: data, | |||
| // datasource_type, | |||
| // datasource_info: datasourceInfo, | |||
| // }) | |||
| // todo: Run Pipeline | |||
| console.log('datasource_type', datasource_type) | |||
| }, [datasource, fileList, notionPages, websiteCrawlJobId, websitePages]) | |||
| if (isFetchingPipelineInfo) { | |||
| return ( | |||
| <Loading type='app' /> | |||
| ) | |||
| } | |||
| return ( | |||
| <div | |||
| className='relative flex h-[calc(100vh-56px)] min-w-[1512px] rounded-t-2xl border-t border-effects-highlight bg-background-default-subtle' | |||
| @@ -140,60 +166,65 @@ const TestRunPanel = () => { | |||
| <div className='grow overflow-y-auto'> | |||
| { | |||
| currentStep === 1 && ( | |||
| <> | |||
| <div className='flex flex-col gap-y-4 px-4 py-2'> | |||
| {/* <DataSourceOptions | |||
| <div className='flex flex-col gap-y-5 pt-4'> | |||
| <DataSourceOptions | |||
| datasourceNodeId={datasource?.nodeId || ''} | |||
| onSelect={setDatasource} | |||
| /> */} | |||
| {datasource?.type === DataSourceType.FILE && ( | |||
| <LocalFile | |||
| files={fileList} | |||
| updateFile={updateFile} | |||
| updateFileList={updateFileList} | |||
| notSupportBatchUpload={notSupportBatchUpload} | |||
| /> | |||
| )} | |||
| {datasource?.type === DataSourceType.NOTION && ( | |||
| <Notion | |||
| nodeId={datasource?.nodeId || ''} | |||
| notionPages={notionPages} | |||
| updateNotionPages={updateNotionPages} | |||
| /> | |||
| )} | |||
| {datasource?.type === DataSourceProvider.fireCrawl && ( | |||
| <FireCrawl | |||
| nodeId={datasource?.nodeId || ''} | |||
| variables={datasource?.variables} | |||
| checkedCrawlResult={websitePages} | |||
| onCheckedCrawlResultChange={setWebsitePages} | |||
| onJobIdChange={setWebsiteCrawlJobId} | |||
| /> | |||
| )} | |||
| {datasource?.type === DataSourceProvider.jinaReader && ( | |||
| <JinaReader | |||
| nodeId={datasource?.nodeId || ''} | |||
| variables={datasource?.variables} | |||
| checkedCrawlResult={websitePages} | |||
| onCheckedCrawlResultChange={setWebsitePages} | |||
| onJobIdChange={setWebsiteCrawlJobId} | |||
| /> | |||
| )} | |||
| {datasource?.type === DataSourceProvider.waterCrawl && ( | |||
| <WaterCrawl | |||
| nodeId={datasource?.nodeId || ''} | |||
| variables={datasource?.variables} | |||
| checkedCrawlResult={websitePages} | |||
| onCheckedCrawlResultChange={setWebsitePages} | |||
| onJobIdChange={setWebsiteCrawlJobId} | |||
| /> | |||
| )} | |||
| {isShowVectorSpaceFull && ( | |||
| <VectorSpaceFull /> | |||
| )} | |||
| </div> | |||
| pipelineNodes={(pipelineInfo?.graph.nodes || []) as Node<DataSourceNodeType>[]} | |||
| /> | |||
| {datasource?.type === DataSourceType.FILE && ( | |||
| <LocalFile | |||
| files={fileList} | |||
| updateFile={updateFile} | |||
| updateFileList={updateFileList} | |||
| onPreview={updateCurrentFile} | |||
| notSupportBatchUpload={notSupportBatchUpload} | |||
| /> | |||
| )} | |||
| {datasource?.type === DataSourceType.NOTION && ( | |||
| <Notion | |||
| nodeId={datasource?.nodeId || ''} | |||
| notionPages={notionPages} | |||
| updateNotionPages={updateNotionPages} | |||
| canPreview | |||
| onPreview={updateCurrentPage} | |||
| /> | |||
| )} | |||
| {datasource?.type === DataSourceProvider.fireCrawl && ( | |||
| <FireCrawl | |||
| nodeId={datasource?.nodeId || ''} | |||
| variables={datasource?.variables} | |||
| checkedCrawlResult={websitePages} | |||
| onCheckedCrawlResultChange={setWebsitePages} | |||
| onJobIdChange={setWebsiteCrawlJobId} | |||
| onPreview={updateCurrentWebsite} | |||
| /> | |||
| )} | |||
| {datasource?.type === DataSourceProvider.jinaReader && ( | |||
| <JinaReader | |||
| nodeId={datasource?.nodeId || ''} | |||
| variables={datasource?.variables} | |||
| checkedCrawlResult={websitePages} | |||
| onCheckedCrawlResultChange={setWebsitePages} | |||
| onJobIdChange={setWebsiteCrawlJobId} | |||
| onPreview={updateCurrentWebsite} | |||
| /> | |||
| )} | |||
| {datasource?.type === DataSourceProvider.waterCrawl && ( | |||
| <WaterCrawl | |||
| nodeId={datasource?.nodeId || ''} | |||
| variables={datasource?.variables} | |||
| checkedCrawlResult={websitePages} | |||
| onCheckedCrawlResultChange={setWebsitePages} | |||
| onJobIdChange={setWebsiteCrawlJobId} | |||
| onPreview={updateCurrentWebsite} | |||
| /> | |||
| )} | |||
| {isShowVectorSpaceFull && ( | |||
| <VectorSpaceFull /> | |||
| )} | |||
| <Actions disabled={nextBtnDisabled} handleNextStep={handleNextStep} /> | |||
| </> | |||
| </div> | |||
| ) | |||
| } | |||
| { | |||
| @@ -209,6 +240,15 @@ const TestRunPanel = () => { | |||
| </div> | |||
| {/* Preview */} | |||
| <div className='flex h-full flex-1 shrink-0 flex-col pl-2 pt-2'> | |||
| { | |||
| currentStep === 1 && ( | |||
| <> | |||
| {currentFile && <FilePreview file={currentFile} hidePreview={hideFilePreview} />} | |||
| {currentNotionPage && <NotionPagePreview currentPage={currentNotionPage} hidePreview={hideNotionPagePreview} />} | |||
| {currentWebsite && <WebsitePreview payload={currentWebsite} hidePreview={hideWebsitePreview} />} | |||
| </> | |||
| ) | |||
| } | |||
| </div> | |||
| </div> | |||
| ) | |||
| @@ -0,0 +1,78 @@ | |||
| 'use client' | |||
| import React from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import Loading from './loading' | |||
| import type { CustomFile as File } from '@/models/datasets' | |||
| import { RiCloseLine } from '@remixicon/react' | |||
| import { useFilePreview } from '@/service/use-common' | |||
| import DocumentFileIcon from '../../../common/document-file-icon' | |||
| import { formatNumberAbbreviated } from '@/utils/format' | |||
| type FilePreviewProps = { | |||
| file: File | |||
| hidePreview: () => void | |||
| } | |||
| const FilePreview = ({ | |||
| file, | |||
| hidePreview, | |||
| }: FilePreviewProps) => { | |||
| const { t } = useTranslation() | |||
| const { data: fileData, isFetching } = useFilePreview(file.id || '') | |||
| const getFileName = (currentFile?: File) => { | |||
| if (!currentFile) | |||
| return '' | |||
| const arr = currentFile.name.split('.') | |||
| return arr.slice(0, -1).join() | |||
| } | |||
| const getFileSize = (size: number) => { | |||
| if (size / 1024 < 10) | |||
| return `${(size / 1024).toFixed(1)} KB` | |||
| return `${(size / 1024 / 1024).toFixed(1)} MB` | |||
| } | |||
| return ( | |||
| <div className='h-full rounded-t-xl border-l border-t border-components-panel-border bg-background-default-lighter shadow-md shadow-shadow-shadow-5'> | |||
| <div className='flex gap-x-2 pb-3 pl-6 pr-4 pt-4'> | |||
| <div className='flex grow flex-col gap-y-1'> | |||
| <div className='system-2xs-semibold-uppercase'>{t('datasetPipeline.addDocuments.stepOne.preview')}</div> | |||
| <div className='title-md-semi-bold text-tex-primary'>{`${getFileName(file)}.${file.extension}`}</div> | |||
| <div className='system-xs-medium flex gap-x-1 text-text-tertiary'> | |||
| <DocumentFileIcon | |||
| className='size-6 shrink-0' | |||
| name={file.name} | |||
| extension={file.extension} | |||
| /> | |||
| <span className='uppercase'>{file.extension}</span> | |||
| <span>·</span> | |||
| <span>{getFileSize(file.size)}</span> | |||
| {fileData && ( | |||
| <> | |||
| <span>·</span> | |||
| <span>{`${formatNumberAbbreviated(fileData.content.length)} ${t('datasetPipeline.addDocuments.characters')}`}</span> | |||
| </> | |||
| )} | |||
| </div> | |||
| </div> | |||
| <button | |||
| type='button' | |||
| className='flex h-8 w-8 shrink-0 items-center justify-center' | |||
| onClick={hidePreview} | |||
| > | |||
| <RiCloseLine className='size-[18px]' /> | |||
| </button> | |||
| </div> | |||
| <div className='px-6 py-5'> | |||
| {isFetching && <Loading />} | |||
| {!isFetching && fileData && ( | |||
| <div className='body-md-regular overflow-hidden text-text-secondary'>{fileData.content}</div> | |||
| )} | |||
| </div> | |||
| </div> | |||
| ) | |||
| } | |||
| export default FilePreview | |||
| @@ -0,0 +1,52 @@ | |||
| import React from 'react' | |||
| import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton' | |||
| const Loading = () => { | |||
| return ( | |||
| <div className='flex h-full flex-col gap-y-12 bg-gradient-to-b from-components-panel-bg-transparent to-components-panel-bg px-6 py-5'> | |||
| <SkeletonContainer className='w-full gap-0'> | |||
| <SkeletonRectangle className='my-1.5 w-full' /> | |||
| <SkeletonRectangle className='my-1.5 w-full' /> | |||
| <SkeletonRectangle className='my-1.5 w-full' /> | |||
| <SkeletonRectangle className='my-1.5 w-full' /> | |||
| <SkeletonRectangle className='my-1.5 w-full' /> | |||
| <SkeletonRectangle className='my-1.5 w-full' /> | |||
| <SkeletonRectangle className='my-1.5 w-full' /> | |||
| <SkeletonRectangle className='my-1.5 w-3/5' /> | |||
| </SkeletonContainer> | |||
| <SkeletonContainer className='w-full gap-0'> | |||
| <SkeletonRectangle className='my-1.5 w-full' /> | |||
| <SkeletonRectangle className='my-1.5 w-full' /> | |||
| <SkeletonRectangle className='my-1.5 w-[70%]' /> | |||
| </SkeletonContainer> | |||
| <SkeletonContainer className='w-full gap-0'> | |||
| <SkeletonRectangle className='my-1.5 w-full' /> | |||
| <SkeletonRectangle className='my-1.5 w-full' /> | |||
| <SkeletonRectangle className='my-1.5 w-full' /> | |||
| <SkeletonRectangle className='my-1.5 w-full' /> | |||
| <SkeletonRectangle className='my-1.5 w-[56%]' /> | |||
| </SkeletonContainer> | |||
| <SkeletonContainer className='w-full gap-0'> | |||
| <SkeletonRectangle className='my-1.5 w-full' /> | |||
| <SkeletonRectangle className='my-1.5 w-full' /> | |||
| <SkeletonRectangle className='my-1.5 w-3/5' /> | |||
| </SkeletonContainer> | |||
| <SkeletonContainer className='w-full gap-0'> | |||
| <SkeletonRectangle className='my-1.5 w-full' /> | |||
| <SkeletonRectangle className='my-1.5 w-full' /> | |||
| <SkeletonRectangle className='my-1.5 w-3/5' /> | |||
| </SkeletonContainer> | |||
| <SkeletonContainer className='w-full gap-0'> | |||
| <SkeletonRectangle className='my-1.5 w-full' /> | |||
| <SkeletonRectangle className='my-1.5 w-full' /> | |||
| <SkeletonRectangle className='my-1.5 w-full' /> | |||
| <SkeletonRectangle className='my-1.5 w-full' /> | |||
| <SkeletonRectangle className='my-1.5 w-full' /> | |||
| <SkeletonRectangle className='my-1.5 w-full' /> | |||
| <SkeletonRectangle className='my-1.5 w-1/2' /> | |||
| </SkeletonContainer> | |||
| </div> | |||
| ) | |||
| } | |||
| export default React.memo(Loading) | |||
| @@ -0,0 +1,65 @@ | |||
| 'use client' | |||
| import React from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import type { NotionPage } from '@/models/common' | |||
| import { usePreviewNotionPage } from '@/service/knowledge/use-dataset' | |||
| import { RiCloseLine } from '@remixicon/react' | |||
| import { formatNumberAbbreviated } from '@/utils/format' | |||
| import Loading from './loading' | |||
| import { Notion } from '@/app/components/base/icons/src/public/common' | |||
| type NotionPagePreviewProps = { | |||
| currentPage: NotionPage | |||
| hidePreview: () => void | |||
| } | |||
| const NotionPagePreview = ({ | |||
| currentPage, | |||
| hidePreview, | |||
| }: NotionPagePreviewProps) => { | |||
| const { t } = useTranslation() | |||
| const { data: notionPageData, isFetching } = usePreviewNotionPage({ | |||
| workspaceID: currentPage.workspace_id, | |||
| pageID: currentPage.page_id, | |||
| pageType: currentPage.type, | |||
| }) | |||
| return ( | |||
| <div className='h-full rounded-t-xl border-l border-t border-components-panel-border bg-background-default-lighter shadow-md shadow-shadow-shadow-5'> | |||
| <div className='flex gap-x-2 pb-3 pl-6 pr-4 pt-4'> | |||
| <div className='flex grow flex-col gap-y-1'> | |||
| <div className='system-2xs-semibold-uppercase'>{t('datasetPipeline.addDocuments.stepOne.preview')}</div> | |||
| <div className='title-md-semi-bold text-tex-primary'>{currentPage?.page_name}</div> | |||
| <div className='system-xs-medium flex gap-x-1 text-text-tertiary'> | |||
| <Notion className='size-3.5' /> | |||
| <span>·</span> | |||
| <span>Notion Page</span> | |||
| <span>·</span> | |||
| {notionPageData && ( | |||
| <> | |||
| <span>·</span> | |||
| <span>{`${formatNumberAbbreviated(notionPageData.content.length)} ${t('datasetPipeline.addDocuments.characters')}`}</span> | |||
| </> | |||
| )} | |||
| </div> | |||
| </div> | |||
| <button | |||
| type='button' | |||
| className='flex h-8 w-8 shrink-0 items-center justify-center' | |||
| onClick={hidePreview} | |||
| > | |||
| <RiCloseLine className='size-[18px]' /> | |||
| </button> | |||
| </div> | |||
| <div className='px-6 py-5'> | |||
| {isFetching && <Loading />} | |||
| {!isFetching && notionPageData && ( | |||
| <div className='body-md-regular overflow-hidden text-text-secondary'>{notionPageData.content}</div> | |||
| )} | |||
| </div> | |||
| </div> | |||
| ) | |||
| } | |||
| export default NotionPagePreview | |||
| @@ -0,0 +1,48 @@ | |||
| 'use client' | |||
| import React from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import type { CrawlResultItem } from '@/models/datasets' | |||
| import { RiCloseLine, RiGlobalLine } from '@remixicon/react' | |||
| import { formatNumberAbbreviated } from '@/utils/format' | |||
| type WebsitePreviewProps = { | |||
| payload: CrawlResultItem | |||
| hidePreview: () => void | |||
| } | |||
| const WebsitePreview = ({ | |||
| payload, | |||
| hidePreview, | |||
| }: WebsitePreviewProps) => { | |||
| const { t } = useTranslation() | |||
| return ( | |||
| <div className='h-full rounded-t-xl border-l border-t border-components-panel-border bg-background-default-lighter shadow-md shadow-shadow-shadow-5'> | |||
| <div className='flex gap-x-2 pb-3 pl-6 pr-4 pt-4'> | |||
| <div className='flex grow flex-col gap-y-1'> | |||
| <div className='system-2xs-semibold-uppercase'>{t('datasetPipeline.addDocuments.stepOne.preview')}</div> | |||
| <div className='title-md-semi-bold text-tex-primary'>{payload.title}</div> | |||
| <div className='system-xs-medium flex gap-x-1 text-text-tertiary'> | |||
| <RiGlobalLine className='size-3.5' /> | |||
| <span className='uppercase' title={payload.source_url}>{payload.source_url}</span> | |||
| <span>·</span> | |||
| <span>·</span> | |||
| <span>{`${formatNumberAbbreviated(payload.markdown.length)} ${t('datasetPipeline.addDocuments.characters')}`}</span> | |||
| </div> | |||
| </div> | |||
| <button | |||
| type='button' | |||
| className='flex h-8 w-8 shrink-0 items-center justify-center' | |||
| onClick={hidePreview} | |||
| > | |||
| <RiCloseLine className='size-[18px]' /> | |||
| </button> | |||
| </div> | |||
| <div className='px-6 py-5'> | |||
| <div className='body-md-regular overflow-hidden text-text-secondary'>{payload.markdown}</div> | |||
| </div> | |||
| </div> | |||
| ) | |||
| } | |||
| export default WebsitePreview | |||
| @@ -23,6 +23,7 @@ type IFileUploaderProps = { | |||
| prepareFileList: (files: FileItem[]) => void | |||
| onFileUpdate: (fileItem: FileItem, progress: number, list: FileItem[]) => void | |||
| onFileListUpdate?: (files: FileItem[]) => void | |||
| onPreview?: (file: File) => void | |||
| notSupportBatchUpload?: boolean | |||
| } | |||
| @@ -31,6 +32,7 @@ const FileUploader = ({ | |||
| prepareFileList, | |||
| onFileUpdate, | |||
| onFileListUpdate, | |||
| onPreview, | |||
| notSupportBatchUpload, | |||
| }: IFileUploaderProps) => { | |||
| const { t } = useTranslation() | |||
| @@ -284,6 +286,7 @@ const FileUploader = ({ | |||
| return ( | |||
| <div | |||
| key={`${fileItem.fileID}-${index}`} | |||
| onClick={() => fileItem.file?.id && onPreview?.(fileItem.file)} | |||
| className={cn( | |||
| 'flex h-12 items-center rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs shadow-shadow-shadow-4', | |||
| isError && 'border-state-destructive-border bg-state-destructive-hover', | |||
| @@ -1,10 +1,11 @@ | |||
| import type { FileItem } from '@/models/datasets' | |||
| import type { CustomFile as File, FileItem } from '@/models/datasets' | |||
| import FileUploader from './file-uploader' | |||
| type LocalFileProps = { | |||
| files: FileItem[] | |||
| updateFileList: (files: FileItem[]) => void | |||
| updateFile: (fileItem: FileItem, progress: number, list: FileItem[]) => void | |||
| onPreview?: (file: File) => void | |||
| notSupportBatchUpload: boolean | |||
| } | |||
| @@ -12,6 +13,7 @@ const LocalFile = ({ | |||
| files, | |||
| updateFileList, | |||
| updateFile, | |||
| onPreview, | |||
| notSupportBatchUpload, | |||
| }: LocalFileProps) => { | |||
| return ( | |||
| @@ -20,6 +22,7 @@ const LocalFile = ({ | |||
| prepareFileList={updateFileList} | |||
| onFileListUpdate={updateFileList} | |||
| onFileUpdate={updateFile} | |||
| onPreview={onPreview} | |||
| notSupportBatchUpload={notSupportBatchUpload} | |||
| /> | |||
| ) | |||
| @@ -5,20 +5,27 @@ type NotionProps = { | |||
| nodeId: string | |||
| notionPages: NotionPage[] | |||
| updateNotionPages: (value: NotionPage[]) => void | |||
| canPreview?: boolean | |||
| onPreview?: (selectedPage: NotionPage) => void | |||
| isInPipeline?: boolean | |||
| } | |||
| const Notion = ({ | |||
| nodeId, | |||
| notionPages, | |||
| updateNotionPages, | |||
| canPreview = false, | |||
| onPreview, | |||
| isInPipeline = false, | |||
| }: NotionProps) => { | |||
| return ( | |||
| <NotionPageSelector | |||
| nodeId={nodeId} | |||
| value={notionPages.map(page => page.page_id)} | |||
| onSelect={updateNotionPages} | |||
| canPreview={false} | |||
| isInPipeline | |||
| canPreview={canPreview} | |||
| onPreview={onPreview} | |||
| isInPipeline={isInPipeline} | |||
| /> | |||
| ) | |||
| } | |||
| @@ -3,23 +3,33 @@ import React, { useCallback } from 'react' | |||
| import cn from '@/utils/classnames' | |||
| import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets' | |||
| import Checkbox from '@/app/components/base/checkbox' | |||
| import Button from '@/app/components/base/button' | |||
| import { useTranslation } from 'react-i18next' | |||
| type CrawledResultItemProps = { | |||
| payload: CrawlResultItemType | |||
| isChecked: boolean | |||
| onCheckChange: (checked: boolean) => void | |||
| isPreview: boolean | |||
| showPreview: boolean | |||
| onPreview: () => void | |||
| } | |||
| const CrawledResultItem = ({ | |||
| payload, | |||
| isChecked, | |||
| onCheckChange, | |||
| isPreview, | |||
| onPreview, | |||
| showPreview, | |||
| }: CrawledResultItemProps) => { | |||
| const { t } = useTranslation() | |||
| const handleCheckChange = useCallback(() => { | |||
| onCheckChange(!isChecked) | |||
| }, [isChecked, onCheckChange]) | |||
| return ( | |||
| <div className={cn('group flex cursor-pointer gap-x-2 rounded-lg p-2 hover:bg-state-base-hover')}> | |||
| <div className={cn('flex cursor-pointer gap-x-2 rounded-lg p-2', isPreview ? 'bg-state-base-active' : 'group hover:bg-state-base-hover')}> | |||
| <Checkbox | |||
| className='shrink-0' | |||
| checked={isChecked} | |||
| @@ -39,6 +49,13 @@ const CrawledResultItem = ({ | |||
| {payload.source_url} | |||
| </div> | |||
| </div> | |||
| {showPreview && <Button | |||
| size='small' | |||
| onClick={onPreview} | |||
| className='system-xs-medium-uppercase right-0 top-0 hidden px-1.5 group-hover:absolute group-hover:block' | |||
| > | |||
| {t('datasetCreation.stepOne.website.preview')} | |||
| </Button>} | |||
| </div> | |||
| ) | |||
| } | |||
| @@ -1,5 +1,5 @@ | |||
| 'use client' | |||
| import React, { useCallback } from 'react' | |||
| import React, { useCallback, useState } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import cn from '@/utils/classnames' | |||
| import type { CrawlResultItem } from '@/models/datasets' | |||
| @@ -13,6 +13,7 @@ type CrawledResultProps = { | |||
| list: CrawlResultItem[] | |||
| checkedList: CrawlResultItem[] | |||
| onSelectedChange: (selected: CrawlResultItem[]) => void | |||
| onPreview?: (payload: CrawlResultItem) => void | |||
| usedTime: number | |||
| } | |||
| @@ -22,8 +23,10 @@ const CrawledResult = ({ | |||
| checkedList, | |||
| onSelectedChange, | |||
| usedTime, | |||
| onPreview, | |||
| }: CrawledResultProps) => { | |||
| const { t } = useTranslation() | |||
| const [previewIndex, setPreviewIndex] = useState<number>(-1) | |||
| const isCheckAll = checkedList.length === list.length | |||
| @@ -45,6 +48,12 @@ const CrawledResult = ({ | |||
| } | |||
| }, [checkedList, onSelectedChange]) | |||
| const handlePreview = useCallback((index: number) => { | |||
| if (!onPreview) return | |||
| setPreviewIndex(index) | |||
| onPreview(list[index]) | |||
| }, [list, onPreview]) | |||
| return ( | |||
| <div className={cn('flex flex-col gap-y-2', className)}> | |||
| <div className='system-sm-medium pt-2 text-text-primary'> | |||
| @@ -61,12 +70,15 @@ const CrawledResult = ({ | |||
| /> | |||
| </div> | |||
| <div className='flex flex-col gap-y-px border-t border-divider-subtle bg-background-default-subtle p-2'> | |||
| {list.map(item => ( | |||
| {list.map((item, index) => ( | |||
| <CrawledResultItem | |||
| key={item.source_url} | |||
| payload={item} | |||
| isChecked={checkedList.some(checkedItem => checkedItem.source_url === item.source_url)} | |||
| onCheckChange={handleItemCheckChange(item)} | |||
| isPreview={index === previewIndex} | |||
| onPreview={handlePreview.bind(null, index)} | |||
| showPreview={!!onPreview} | |||
| /> | |||
| ))} | |||
| </div> | |||
| @@ -22,6 +22,7 @@ type CrawlerProps = { | |||
| datasourceProvider: DataSourceProvider | |||
| onCheckedCrawlResultChange: (payload: CrawlResultItem[]) => void | |||
| onJobIdChange: (jobId: string) => void | |||
| onPreview?: (payload: CrawlResultItem) => void | |||
| } | |||
| enum Step { | |||
| @@ -37,6 +38,7 @@ const Crawler = ({ | |||
| datasourceProvider, | |||
| onCheckedCrawlResultChange, | |||
| onJobIdChange, | |||
| onPreview, | |||
| }: CrawlerProps) => { | |||
| const { t } = useTranslation() | |||
| const [step, setStep] = useState<Step>(Step.init) | |||
| @@ -123,6 +125,7 @@ const Crawler = ({ | |||
| checkedList={checkedCrawlResult} | |||
| onSelectedChange={onCheckedCrawlResultChange} | |||
| usedTime={Number.parseFloat(crawlResult?.time_consuming as string) || 0} | |||
| onPreview={onPreview} | |||
| /> | |||
| )} | |||
| </div> | |||
| @@ -11,6 +11,7 @@ type FireCrawlProps = { | |||
| checkedCrawlResult: CrawlResultItem[] | |||
| onCheckedCrawlResultChange: (payload: CrawlResultItem[]) => void | |||
| onJobIdChange: (jobId: string) => void | |||
| onPreview?: (payload: CrawlResultItem) => void | |||
| } | |||
| const FireCrawl = ({ | |||
| @@ -19,6 +20,7 @@ const FireCrawl = ({ | |||
| checkedCrawlResult, | |||
| onCheckedCrawlResultChange, | |||
| onJobIdChange, | |||
| onPreview, | |||
| }: FireCrawlProps) => { | |||
| return ( | |||
| <Crawler | |||
| @@ -28,6 +30,7 @@ const FireCrawl = ({ | |||
| datasourceProvider={DataSourceProvider.fireCrawl} | |||
| onCheckedCrawlResultChange={onCheckedCrawlResultChange} | |||
| onJobIdChange={onJobIdChange} | |||
| onPreview={onPreview} | |||
| /> | |||
| ) | |||
| } | |||
| @@ -11,6 +11,7 @@ type JinaReaderProps = { | |||
| checkedCrawlResult: CrawlResultItem[] | |||
| onCheckedCrawlResultChange: (payload: CrawlResultItem[]) => void | |||
| onJobIdChange: (jobId: string) => void | |||
| onPreview?: (payload: CrawlResultItem) => void | |||
| } | |||
| const JinaReader = ({ | |||
| @@ -19,6 +20,7 @@ const JinaReader = ({ | |||
| checkedCrawlResult, | |||
| onCheckedCrawlResultChange, | |||
| onJobIdChange, | |||
| onPreview, | |||
| }: JinaReaderProps) => { | |||
| return ( | |||
| <Crawler | |||
| @@ -28,6 +30,7 @@ const JinaReader = ({ | |||
| datasourceProvider={DataSourceProvider.jinaReader} | |||
| onCheckedCrawlResultChange={onCheckedCrawlResultChange} | |||
| onJobIdChange={onJobIdChange} | |||
| onPreview={onPreview} | |||
| /> | |||
| ) | |||
| } | |||
| @@ -11,6 +11,7 @@ type WaterCrawlProps = { | |||
| checkedCrawlResult: CrawlResultItem[] | |||
| onCheckedCrawlResultChange: (payload: CrawlResultItem[]) => void | |||
| onJobIdChange: (jobId: string) => void | |||
| onPreview?: (payload: CrawlResultItem) => void | |||
| } | |||
| const WaterCrawl = ({ | |||
| @@ -19,6 +20,7 @@ const WaterCrawl = ({ | |||
| checkedCrawlResult, | |||
| onCheckedCrawlResultChange, | |||
| onJobIdChange, | |||
| onPreview, | |||
| }: WaterCrawlProps) => { | |||
| return ( | |||
| <Crawler | |||
| @@ -28,6 +30,7 @@ const WaterCrawl = ({ | |||
| datasourceProvider={DataSourceProvider.jinaReader} | |||
| onCheckedCrawlResultChange={onCheckedCrawlResultChange} | |||
| onJobIdChange={onJobIdChange} | |||
| onPreview={onPreview} | |||
| /> | |||
| ) | |||
| } | |||
| @@ -78,6 +78,10 @@ const translation = { | |||
| processingDocuments: 'Processing Documents', | |||
| }, | |||
| backToDataSource: 'Data Source', | |||
| stepOne: { | |||
| preview: 'Preview', | |||
| }, | |||
| characters: 'characters', | |||
| }, | |||
| } | |||
| @@ -78,6 +78,10 @@ const translation = { | |||
| processingDocuments: '正在处理文档', | |||
| }, | |||
| backToDataSource: '数据源', | |||
| stepOne: { | |||
| preview: '预览', | |||
| }, | |||
| characters: '字符', | |||
| }, | |||
| } | |||
| @@ -44,3 +44,22 @@ export const useDatasetRelatedApps = (datasetId: string) => { | |||
| queryFn: () => get<RelatedAppResponse>(`/datasets/${datasetId}/related-apps`), | |||
| }) | |||
| } | |||
| type NotionPagePreviewRequest = { | |||
| workspaceID: string | |||
| pageID: string | |||
| pageType: string | |||
| } | |||
| type NotionPagePreviewResponse = { | |||
| content: string | |||
| } | |||
| export const usePreviewNotionPage = (params: NotionPagePreviewRequest) => { | |||
| const { workspaceID, pageID, pageType } = params | |||
| return useQuery({ | |||
| queryKey: [NAME_SPACE, 'preview-notion-page'], | |||
| queryFn: () => get<NotionPagePreviewResponse>(`notion/workspaces/${workspaceID}/pages/${pageID}/${pageType}/preview`), | |||
| enabled: !!workspaceID && !!pageID && !!pageType, | |||
| }) | |||
| } | |||
| @@ -60,3 +60,15 @@ export const useMembers = () => { | |||
| }), | |||
| }) | |||
| } | |||
| type FilePreviewResponse = { | |||
| content: string | |||
| } | |||
| export const useFilePreview = (fileID: string) => { | |||
| return useQuery<FilePreviewResponse>({ | |||
| queryKey: [NAME_SPACE, 'file-preview', fileID], | |||
| queryFn: () => get<FilePreviewResponse>(`/files/${fileID}/preview`), | |||
| enabled: !!fileID, | |||
| }) | |||
| } | |||
| @@ -164,5 +164,6 @@ export const usePublishedPipelineInfo = (pipelineId: string) => { | |||
| queryFn: () => { | |||
| return get<PublishedPipelineInfoResponse>(`/rag/pipelines/${pipelineId}/workflows/publish`) | |||
| }, | |||
| enabled: !!pipelineId, | |||
| }) | |||
| } | |||
| @@ -56,3 +56,35 @@ export const downloadFile = ({ data, fileName }: { data: Blob; fileName: string | |||
| a.remove() | |||
| window.URL.revokeObjectURL(url) | |||
| } | |||
| /** | |||
| * Formats a number into a readable string using "k", "M", or "B" suffix. | |||
| * @example | |||
| * 950 => "950" | |||
| * 1200 => "1.2k" | |||
| * 1500000 => "1.5M" | |||
| * 2000000000 => "2B" | |||
| * | |||
| * @param {number} num - The number to format | |||
| * @returns {string} - The formatted number string | |||
| */ | |||
| export const formatNumberAbbreviated = (num: number) => { | |||
| // If less than 1000, return as-is | |||
| if (num < 1000) return num.toString() | |||
| // Define thresholds and suffixes | |||
| const units = [ | |||
| { value: 1e9, symbol: 'B' }, | |||
| { value: 1e6, symbol: 'M' }, | |||
| { value: 1e3, symbol: 'k' }, | |||
| ] | |||
| for (let i = 0; i < units.length; i++) { | |||
| if (num >= units[i].value) { | |||
| const formatted = (num / units[i].value).toFixed(1) | |||
| return formatted.endsWith('.0') | |||
| ? `${Number.parseInt(formatted)}${units[i].symbol}` | |||
| : `${formatted}${units[i].symbol}` | |||
| } | |||
| } | |||
| } | |||