瀏覽代碼

Fix: disable operations of dataset when embedding unavailable (#1055)

Co-authored-by: jyong <jyong@dify.ai>
KVOJJJin 1 年之前
父節點
當前提交
c67f345d0e

+ 17 - 3
api/controllers/console/datasets/datasets.py

@@ -148,14 +148,28 @@ class DatasetApi(Resource):
         dataset = DatasetService.get_dataset(dataset_id_str)
         if dataset is None:
             raise NotFound("Dataset not found.")
-
         try:
             DatasetService.check_dataset_permission(
                 dataset, current_user)
         except services.errors.account.NoPermissionError as e:
             raise Forbidden(str(e))
-
-        return marshal(dataset, dataset_detail_fields), 200
+        data = marshal(dataset, dataset_detail_fields)
+        # check embedding setting
+        provider_service = ProviderService()
+        # get valid model list
+        valid_model_list = provider_service.get_valid_model_list(current_user.current_tenant_id, ModelType.EMBEDDINGS.value)
+        model_names = []
+        for valid_model in valid_model_list:
+            model_names.append(f"{valid_model['model_name']}:{valid_model['model_provider']['provider_name']}")
+        if data['indexing_technique'] == 'high_quality':
+            item_model = f"{data['embedding_model']}:{data['embedding_model_provider']}"
+            if item_model in model_names:
+                data['embedding_available'] = True
+            else:
+                data['embedding_available'] = False
+        else:
+            data['embedding_available'] = True
+        return data, 200
 
     @setup_required
     @login_required

+ 13 - 9
api/services/dataset_service.py

@@ -137,28 +137,31 @@ class DatasetService:
 
     @staticmethod
     def update_dataset(dataset_id, data, user):
+        filtered_data = {k: v for k, v in data.items() if v is not None or k == 'description'}
         dataset = DatasetService.get_dataset(dataset_id)
         DatasetService.check_dataset_permission(dataset, user)
+        action = None
         if dataset.indexing_technique != data['indexing_technique']:
             # if update indexing_technique
             if data['indexing_technique'] == 'economy':
-                deal_dataset_vector_index_task.delay(dataset_id, 'remove')
+                action = 'remove'
+                filtered_data['embedding_model'] = None
+                filtered_data['embedding_model_provider'] = None
             elif data['indexing_technique'] == 'high_quality':
-                # check embedding model setting
+                action = 'add'
+                # get embedding model setting
                 try:
-                    ModelFactory.get_embedding_model(
-                        tenant_id=current_user.current_tenant_id,
-                        model_provider_name=dataset.embedding_model_provider,
-                        model_name=dataset.embedding_model
+                    embedding_model = ModelFactory.get_embedding_model(
+                        tenant_id=current_user.current_tenant_id
                     )
+                    filtered_data['embedding_model'] = embedding_model.name
+                    filtered_data['embedding_model_provider'] = embedding_model.model_provider.provider_name
                 except LLMBadRequestError:
                     raise ValueError(
                         f"No Embedding Model available. Please configure a valid provider "
                         f"in the Settings -> Model Provider.")
                 except ProviderTokenNotInitError as ex:
                     raise ValueError(ex.description)
-                deal_dataset_vector_index_task.delay(dataset_id, 'add')
-        filtered_data = {k: v for k, v in data.items() if v is not None or k == 'description'}
 
         filtered_data['updated_by'] = user.id
         filtered_data['updated_at'] = datetime.datetime.now()
@@ -166,7 +169,8 @@ class DatasetService:
         dataset.query.filter_by(id=dataset_id).update(filtered_data)
 
         db.session.commit()
-
+        if action:
+            deal_dataset_vector_index_task.delay(dataset_id, action)
         return dataset
 
     @staticmethod

+ 1 - 1
web/app/components/app/configuration/dataset-config/card-item/index.tsx

@@ -43,7 +43,7 @@ const CardItem: FC<ICardItemProps> = ({
                 selector={`unavailable-tag-${config.id}`}
                 htmlContent={t('dataset.unavailableTip')}
               >
-                <span className='shrink-0 px-1 border boder-gray-200 rounded-md text-gray-500 text-xs font-normal leading-[18px]'>{t('dataset.unavailable')}</span>
+                <span className='shrink-0 inline-flex whitespace-nowrap px-1 border boder-gray-200 rounded-md text-gray-500 text-xs font-normal leading-[18px]'>{t('dataset.unavailable')}</span>
               </Tooltip>
             )}
           </div>

+ 4 - 2
web/app/components/datasets/documents/detail/completed/InfiniteVirtualList.tsx

@@ -15,7 +15,7 @@ type IInfiniteVirtualListProps = {
   onChangeSwitch: (segId: string, enabled: boolean) => Promise<void>
   onDelete: (segId: string) => Promise<void>
   archived?: boolean
-
+  embeddingAvailable: boolean
 }
 
 const InfiniteVirtualList: FC<IInfiniteVirtualListProps> = ({
@@ -27,6 +27,7 @@ const InfiniteVirtualList: FC<IInfiniteVirtualListProps> = ({
   onChangeSwitch,
   onDelete,
   archived,
+  embeddingAvailable,
 }) => {
   // If there are more items to be loaded then add an extra row to hold a loading indicator.
   const itemCount = hasNextPage ? items.length + 1 : items.length
@@ -45,7 +46,7 @@ const InfiniteVirtualList: FC<IInfiniteVirtualListProps> = ({
       content = (
         <>
           {[1, 2, 3].map(v => (
-            <SegmentCard loading={true} detail={{ position: v } as any} />
+            <SegmentCard key={v} loading={true} detail={{ position: v } as any} />
           ))}
         </>
       )
@@ -60,6 +61,7 @@ const InfiniteVirtualList: FC<IInfiniteVirtualListProps> = ({
           onDelete={onDelete}
           loading={false}
           archived={archived}
+          embeddingAvailable={embeddingAvailable}
         />
       ))
     }

+ 22 - 18
web/app/components/datasets/documents/detail/completed/SegmentCard.tsx

@@ -43,6 +43,7 @@ type ISegmentCardProps = {
   scene?: UsageScene
   className?: string
   archived?: boolean
+  embeddingAvailable: boolean
 }
 
 const SegmentCard: FC<ISegmentCardProps> = ({
@@ -55,6 +56,7 @@ const SegmentCard: FC<ISegmentCardProps> = ({
   scene = 'doc',
   className = '',
   archived,
+  embeddingAvailable,
 }) => {
   const { t } = useTranslation()
   const {
@@ -115,24 +117,26 @@ const SegmentCard: FC<ISegmentCardProps> = ({
                 : (
                   <>
                     <StatusItem status={enabled ? 'enabled' : 'disabled'} reverse textCls="text-gray-500 text-xs" />
-                    <div className="hidden group-hover:inline-flex items-center">
-                      <Divider type="vertical" className="!h-2" />
-                      <div
-                        onClick={(e: React.MouseEvent<HTMLDivElement, MouseEvent>) =>
-                          e.stopPropagation()
-                        }
-                        className="inline-flex items-center"
-                      >
-                        <Switch
-                          size='md'
-                          disabled={archived}
-                          defaultValue={enabled}
-                          onChange={async (val) => {
-                            await onChangeSwitch?.(id, val)
-                          }}
-                        />
+                    {embeddingAvailable && (
+                      <div className="hidden group-hover:inline-flex items-center">
+                        <Divider type="vertical" className="!h-2" />
+                        <div
+                          onClick={(e: React.MouseEvent<HTMLDivElement, MouseEvent>) =>
+                            e.stopPropagation()
+                          }
+                          className="inline-flex items-center"
+                        >
+                          <Switch
+                            size='md'
+                            disabled={archived}
+                            defaultValue={enabled}
+                            onChange={async (val) => {
+                              await onChangeSwitch?.(id, val)
+                            }}
+                          />
+                        </div>
                       </div>
-                    </div>
+                    )}
                   </>
                 )}
             </div>
@@ -173,7 +177,7 @@ const SegmentCard: FC<ISegmentCardProps> = ({
                   <div className={cn(s.commonIcon, s.bezierCurveIcon)} />
                   <div className={s.segDataText}>{index_node_hash}</div>
                 </div>
-                {!archived && (
+                {!archived && embeddingAvailable && (
                   <div className='shrink-0 w-6 h-6 flex items-center justify-center rounded-md hover:bg-red-100 hover:text-red-600 cursor-pointer group/delete' onClick={(e) => {
                     e.stopPropagation()
                     setShowModal(true)

+ 20 - 10
web/app/components/datasets/documents/detail/completed/index.tsx

@@ -46,6 +46,7 @@ export const SegmentIndexTag: FC<{ positionId: string | number; className?: stri
 }
 
 type ISegmentDetailProps = {
+  embeddingAvailable: boolean
   segInfo?: Partial<SegmentDetailModel> & { id: string }
   onChangeSwitch?: (segId: string, enabled: boolean) => Promise<void>
   onUpdate: (segmentId: string, q: string, a: string, k: string[]) => void
@@ -56,6 +57,7 @@ type ISegmentDetailProps = {
  * Show all the contents of the segment
  */
 const SegmentDetailComponent: FC<ISegmentDetailProps> = ({
+  embeddingAvailable,
   segInfo,
   archived,
   onChangeSwitch,
@@ -146,7 +148,7 @@ const SegmentDetailComponent: FC<ISegmentDetailProps> = ({
             </Button>
           </>
         )}
-        {!isEditing && !archived && (
+        {!isEditing && !archived && embeddingAvailable && (
           <>
             <div className='group relative flex justify-center items-center w-6 h-6 hover:bg-gray-100 rounded-md cursor-pointer'>
               <div className={cn(s.editTip, 'hidden items-center absolute -top-10 px-3 h-[34px] bg-white rounded-lg whitespace-nowrap text-xs font-semibold text-gray-700 group-hover:flex')}>{t('common.operation.edit')}</div>
@@ -183,15 +185,19 @@ const SegmentDetailComponent: FC<ISegmentDetailProps> = ({
         </div>
         <div className='flex items-center'>
           <StatusItem status={segInfo?.enabled ? 'enabled' : 'disabled'} reverse textCls='text-gray-500 text-xs' />
-          <Divider type='vertical' className='!h-2' />
-          <Switch
-            size='md'
-            defaultValue={segInfo?.enabled}
-            onChange={async (val) => {
-              await onChangeSwitch?.(segInfo?.id || '', val)
-            }}
-            disabled={archived}
-          />
+          {embeddingAvailable && (
+            <>
+              <Divider type='vertical' className='!h-2' />
+              <Switch
+                size='md'
+                defaultValue={segInfo?.enabled}
+                onChange={async (val) => {
+                  await onChangeSwitch?.(segInfo?.id || '', val)
+                }}
+                disabled={archived}
+              />
+            </>
+          )}
         </div>
       </div>
     </div>
@@ -209,6 +215,7 @@ export const splitArray = (arr: any[], size = 3) => {
 }
 
 type ICompletedProps = {
+  embeddingAvailable: boolean
   showNewSegmentModal: boolean
   onNewSegmentModalChange: (state: boolean) => void
   importStatus: ProcessStatus | string | undefined
@@ -220,6 +227,7 @@ type ICompletedProps = {
  * Support search and filter
  */
 const Completed: FC<ICompletedProps> = ({
+  embeddingAvailable,
   showNewSegmentModal,
   onNewSegmentModalChange,
   importStatus,
@@ -384,6 +392,7 @@ const Completed: FC<ICompletedProps> = ({
         <Input showPrefix wrapperClassName='!w-52' className='!h-8' onChange={debounce(setSearchValue, 500)} />
       </div>
       <InfiniteVirtualList
+        embeddingAvailable={embeddingAvailable}
         hasNextPage={lastSegmentsRes?.has_more ?? true}
         isNextPageLoading={loading}
         items={allSegments}
@@ -395,6 +404,7 @@ const Completed: FC<ICompletedProps> = ({
       />
       <Modal isShow={currSegment.showModal} onClose={() => {}} className='!max-w-[640px] !overflow-visible'>
         <SegmentDetail
+          embeddingAvailable={embeddingAvailable}
           segInfo={currSegment.segInfo ?? { id: '' }}
           onChangeSwitch={onChangeSwitch}
           onUpdate={handleUpdateSegment}

+ 6 - 1
web/app/components/datasets/documents/detail/index.tsx

@@ -22,6 +22,7 @@ import type { MetadataType } from '@/service/datasets'
 import { checkSegmentBatchImportProgress, fetchDocumentDetail, segmentBatchImport } from '@/service/datasets'
 import { ToastContext } from '@/app/components/base/toast'
 import type { DocForm } from '@/models/datasets'
+import { useDatasetDetailContext } from '@/context/dataset-detail'
 
 export const DocumentContext = createContext<{ datasetId?: string; documentId?: string; docForm: string }>({ docForm: '' })
 
@@ -50,6 +51,8 @@ const DocumentDetail: FC<Props> = ({ datasetId, documentId }) => {
   const router = useRouter()
   const { t } = useTranslation()
   const { notify } = useContext(ToastContext)
+  const { dataset } = useDatasetDetailContext()
+  const embeddingAvailable = !!dataset?.embedding_available
   const [showMetadata, setShowMetadata] = useState(true)
   const [newSegmentModalVisible, setNewSegmentModalVisible] = useState(false)
   const [batchModalVisible, setBatchModalVisible] = useState(false)
@@ -128,7 +131,7 @@ const DocumentDetail: FC<Props> = ({ datasetId, documentId }) => {
           <Divider className='!h-4' type='vertical' />
           <DocumentTitle extension={documentDetail?.data_source_info?.upload_file?.extension} name={documentDetail?.name} />
           <StatusItem status={documentDetail?.display_status || 'available'} scene='detail' errorMessage={documentDetail?.error || ''} />
-          {documentDetail && !documentDetail.archived && (
+          {embeddingAvailable && documentDetail && !documentDetail.archived && (
             <SegmentAdd
               importStatus={importStatus}
               clearProcessStatus={resetProcessStatus}
@@ -138,6 +141,7 @@ const DocumentDetail: FC<Props> = ({ datasetId, documentId }) => {
           )}
           <OperationAction
             scene='detail'
+            embeddingAvailable={embeddingAvailable}
             detail={{
               enabled: documentDetail?.enabled || false,
               archived: documentDetail?.archived || false,
@@ -161,6 +165,7 @@ const DocumentDetail: FC<Props> = ({ datasetId, documentId }) => {
               {embedding
                 ? <Embedding detail={documentDetail} detailUpdate={detailMutate} />
                 : <Completed
+                  embeddingAvailable={embeddingAvailable}
                   showNewSegmentModal={newSegmentModalVisible}
                   onNewSegmentModalChange={setNewSegmentModalVisible}
                   importStatus={importStatus}

+ 12 - 12
web/app/components/datasets/documents/index.tsx

@@ -51,7 +51,7 @@ const NotionIcon = ({ className }: React.SVGProps<SVGElement>) => {
   </svg>
 }
 
-const EmptyElement: FC<{ onClick: () => void; type?: 'upload' | 'sync' }> = ({ onClick, type = 'upload' }) => {
+const EmptyElement: FC<{ canAdd: boolean; onClick: () => void; type?: 'upload' | 'sync' }> = ({ canAdd = true, onClick, type = 'upload' }) => {
   const { t } = useTranslation()
   return <div className={s.emptyWrapper}>
     <div className={s.emptyElement}>
@@ -62,7 +62,7 @@ const EmptyElement: FC<{ onClick: () => void; type?: 'upload' | 'sync' }> = ({ o
       <div className={s.emptyTip}>
         {t(`datasetDocuments.list.empty.${type}.tip`)}
       </div>
-      {type === 'upload' && <Button onClick={onClick} className={s.addFileBtn}>
+      {type === 'upload' && canAdd && <Button onClick={onClick} className={s.addFileBtn}>
         <PlusIcon className={s.plusIcon} />{t('datasetDocuments.list.addFile')}
       </Button>}
     </div>
@@ -84,6 +84,7 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
   const [notionPageSelectorModalVisible, setNotionPageSelectorModalVisible] = useState(false)
   const [timerCanRun, setTimerCanRun] = useState(true)
   const isDataSourceNotion = dataset?.data_source_type === DataSourceType.NOTION
+  const embeddingAvailable = !!dataset?.embedding_available
 
   const query = useMemo(() => {
     return { page: currPage + 1, limit, keyword: searchValue, fetch: isDataSourceNotion ? true : '' }
@@ -205,20 +206,19 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
             onChange={debounce(setSearchValue, 500)}
             value={searchValue}
           />
-          <Button type='primary' onClick={routeToDocCreate} className='!h-8 !text-[13px]'>
-            <PlusIcon className='h-4 w-4 mr-2 stroke-current' />
-            {
-              isDataSourceNotion
-                ? t('datasetDocuments.list.addPages')
-                : t('datasetDocuments.list.addFile')
-            }
-          </Button>
+          {embeddingAvailable && (
+            <Button type='primary' onClick={routeToDocCreate} className='!h-8 !text-[13px]'>
+              <PlusIcon className='h-4 w-4 mr-2 stroke-current' />
+              {isDataSourceNotion && t('datasetDocuments.list.addPages')}
+              {!isDataSourceNotion && t('datasetDocuments.list.addFile')}
+            </Button>
+          )}
         </div>
         {isLoading
           ? <Loading type='app' />
           : total > 0
-            ? <List documents={documentsList || []} datasetId={datasetId} onUpdate={mutate} />
-            : <EmptyElement onClick={routeToDocCreate} type={isDataSourceNotion ? 'sync' : 'upload'} />
+            ? <List embeddingAvailable={embeddingAvailable} documents={documentsList || []} datasetId={datasetId} onUpdate={mutate} />
+            : <EmptyElement canAdd={embeddingAvailable} onClick={routeToDocCreate} type={isDataSourceNotion ? 'sync' : 'upload'} />
         }
         {/* Show Pagination only if the total is more than the limit */}
         {(total && total > limit)

+ 87 - 77
web/app/components/datasets/documents/list.tsx

@@ -103,6 +103,7 @@ type OperationName = 'delete' | 'archive' | 'enable' | 'disable' | 'sync' | 'un_
 
 // operation action for list and detail
 export const OperationAction: FC<{
+  embeddingAvailable: boolean
   detail: {
     enabled: boolean
     archived: boolean
@@ -114,7 +115,7 @@ export const OperationAction: FC<{
   onUpdate: (operationName?: string) => void
   scene?: 'list' | 'detail'
   className?: string
-}> = ({ datasetId, detail, onUpdate, scene = 'list', className = '' }) => {
+}> = ({ embeddingAvailable, datasetId, detail, onUpdate, scene = 'list', className = '' }) => {
   const { id, enabled = false, archived = false, data_source_type } = detail || {}
   const [showModal, setShowModal] = useState(false)
   const { notify } = useContext(ToastContext)
@@ -154,87 +155,94 @@ export const OperationAction: FC<{
   }
 
   return <div className='flex items-center' onClick={e => e.stopPropagation()}>
-    {isListScene && <>
-      {archived
-        ? <Tooltip selector={`list-switch-${id}`} content={t('datasetDocuments.list.action.enableWarning') as string} className='!font-semibold'>
-          <div>
-            <Switch defaultValue={false} onChange={() => { }} disabled={true} size='md' />
-          </div>
-        </Tooltip>
-        : <Switch defaultValue={enabled} onChange={v => onOperate(v ? 'enable' : 'disable')} size='md' />
-      }
-      <Divider className='!ml-4 !mr-2 !h-3' type='vertical' />
-    </>}
-    <Popover
-      htmlContent={
-        <div className='w-full py-1'>
-          {!isListScene && <>
-            <div className='flex justify-between items-center mx-4 pt-2'>
-              <span className={cn(s.actionName, 'font-medium')}>
-                {!archived && enabled ? t('datasetDocuments.list.index.enable') : t('datasetDocuments.list.index.disable')}
-              </span>
-              <Tooltip
-                selector={`detail-switch-${id}`}
-                content={t('datasetDocuments.list.action.enableWarning') as string}
-                className='!font-semibold'
-                disabled={!archived}
-              >
-                <div>
-                  <Switch
-                    defaultValue={archived ? false : enabled}
-                    onChange={v => !archived && onOperate(v ? 'enable' : 'disable')}
-                    disabled={archived}
-                    size='md'
-                  />
-                </div>
-              </Tooltip>
+    {isListScene && !embeddingAvailable && (
+      <Switch defaultValue={false} onChange={() => { }} disabled={true} size='md' />
+    )}
+    {isListScene && embeddingAvailable && (
+      <>
+        {archived
+          ? <Tooltip selector={`list-switch-${id}`} content={t('datasetDocuments.list.action.enableWarning') as string} className='!font-semibold'>
+            <div>
+              <Switch defaultValue={false} onChange={() => { }} disabled={true} size='md' />
             </div>
-            <div className='mx-4 pb-1 pt-0.5 text-xs text-gray-500'>
-              {!archived && enabled ? t('datasetDocuments.list.index.enableTip') : t('datasetDocuments.list.index.disableTip')}
-            </div>
-            <Divider />
-          </>}
-          {!archived && (
-            <>
-              <div className={s.actionItem} onClick={() => router.push(`/datasets/${datasetId}/documents/${detail.id}/settings`)}>
-                <SettingsIcon />
-                <span className={s.actionName}>{t('datasetDocuments.list.action.settings')}</span>
+          </Tooltip>
+          : <Switch defaultValue={enabled} onChange={v => onOperate(v ? 'enable' : 'disable')} size='md' />
+        }
+        <Divider className='!ml-4 !mr-2 !h-3' type='vertical' />
+      </>
+    )}
+    {embeddingAvailable && (
+      <Popover
+        htmlContent={
+          <div className='w-full py-1'>
+            {!isListScene && <>
+              <div className='flex justify-between items-center mx-4 pt-2'>
+                <span className={cn(s.actionName, 'font-medium')}>
+                  {!archived && enabled ? t('datasetDocuments.list.index.enable') : t('datasetDocuments.list.index.disable')}
+                </span>
+                <Tooltip
+                  selector={`detail-switch-${id}`}
+                  content={t('datasetDocuments.list.action.enableWarning') as string}
+                  className='!font-semibold'
+                  disabled={!archived}
+                >
+                  <div>
+                    <Switch
+                      defaultValue={archived ? false : enabled}
+                      onChange={v => !archived && onOperate(v ? 'enable' : 'disable')}
+                      disabled={archived}
+                      size='md'
+                    />
+                  </div>
+                </Tooltip>
+              </div>
+              <div className='mx-4 pb-1 pt-0.5 text-xs text-gray-500'>
+                {!archived && enabled ? t('datasetDocuments.list.index.enableTip') : t('datasetDocuments.list.index.disableTip')}
               </div>
-              {data_source_type === 'notion_import' && (
-                <div className={s.actionItem} onClick={() => onOperate('sync')}>
-                  <SyncIcon />
-                  <span className={s.actionName}>{t('datasetDocuments.list.action.sync')}</span>
+              <Divider />
+            </>}
+            {!archived && (
+              <>
+                <div className={s.actionItem} onClick={() => router.push(`/datasets/${datasetId}/documents/${detail.id}/settings`)}>
+                  <SettingsIcon />
+                  <span className={s.actionName}>{t('datasetDocuments.list.action.settings')}</span>
                 </div>
-              )}
-              <Divider className='my-1' />
-            </>
-          )}
-          {!archived && <div className={s.actionItem} onClick={() => onOperate('archive')}>
-            <ArchiveIcon />
-            <span className={s.actionName}>{t('datasetDocuments.list.action.archive')}</span>
-          </div>}
-          {archived && (
-            <div className={s.actionItem} onClick={() => onOperate('un_archive')}>
+                {data_source_type === 'notion_import' && (
+                  <div className={s.actionItem} onClick={() => onOperate('sync')}>
+                    <SyncIcon />
+                    <span className={s.actionName}>{t('datasetDocuments.list.action.sync')}</span>
+                  </div>
+                )}
+                <Divider className='my-1' />
+              </>
+            )}
+            {!archived && <div className={s.actionItem} onClick={() => onOperate('archive')}>
               <ArchiveIcon />
-              <span className={s.actionName}>{t('datasetDocuments.list.action.unarchive')}</span>
+              <span className={s.actionName}>{t('datasetDocuments.list.action.archive')}</span>
+            </div>}
+            {archived && (
+              <div className={s.actionItem} onClick={() => onOperate('un_archive')}>
+                <ArchiveIcon />
+                <span className={s.actionName}>{t('datasetDocuments.list.action.unarchive')}</span>
+              </div>
+            )}
+            <div className={cn(s.actionItem, s.deleteActionItem, 'group')} onClick={() => setShowModal(true)}>
+              <TrashIcon className={'w-4 h-4 stroke-current text-gray-500 stroke-2 group-hover:text-red-500'} />
+              <span className={cn(s.actionName, 'group-hover:text-red-500')}>{t('datasetDocuments.list.action.delete')}</span>
             </div>
-          )}
-          <div className={cn(s.actionItem, s.deleteActionItem, 'group')} onClick={() => setShowModal(true)}>
-            <TrashIcon className={'w-4 h-4 stroke-current text-gray-500 stroke-2 group-hover:text-red-500'} />
-            <span className={cn(s.actionName, 'group-hover:text-red-500')}>{t('datasetDocuments.list.action.delete')}</span>
           </div>
-        </div>
-      }
-      trigger='click'
-      position='br'
-      btnElement={
-        <div className={cn(s.commonIcon)}>
-          <DotsHorizontal className='w-4 h-4 text-gray-700' />
-        </div>
-      }
-      btnClassName={open => cn(isListScene ? s.actionIconWrapperList : s.actionIconWrapperDetail, open ? '!bg-gray-100 !shadow-none' : '!bg-transparent')}
-      className={`!w-[200px] h-fit !z-20 ${className}`}
-    />
+        }
+        trigger='click'
+        position='br'
+        btnElement={
+          <div className={cn(s.commonIcon)}>
+            <DotsHorizontal className='w-4 h-4 text-gray-700' />
+          </div>
+        }
+        btnClassName={open => cn(isListScene ? s.actionIconWrapperList : s.actionIconWrapperDetail, open ? '!bg-gray-100 !shadow-none' : '!bg-transparent')}
+        className={`!w-[200px] h-fit !z-20 ${className}`}
+      />
+    )}
     {showModal && <Modal isShow={showModal} onClose={() => setShowModal(false)} className={s.delModal} closable>
       <div>
         <div className={s.warningWrapper}>
@@ -277,6 +285,7 @@ const renderCount = (count: number | undefined) => {
 
 type LocalDoc = SimpleDocumentDetail & { percent?: number }
 type IDocumentListProps = {
+  embeddingAvailable: boolean
   documents: LocalDoc[]
   datasetId: string
   onUpdate: () => void
@@ -285,7 +294,7 @@ type IDocumentListProps = {
 /**
  * Document list component including basic information
  */
-const DocumentList: FC<IDocumentListProps> = ({ documents = [], datasetId, onUpdate }) => {
+const DocumentList: FC<IDocumentListProps> = ({ embeddingAvailable, documents = [], datasetId, onUpdate }) => {
   const { t } = useTranslation()
   const router = useRouter()
   const [localDocs, setLocalDocs] = useState<LocalDoc[]>(documents)
@@ -361,6 +370,7 @@ const DocumentList: FC<IDocumentListProps> = ({ documents = [], datasetId, onUpd
               </td>
               <td>
                 <OperationAction
+                  embeddingAvailable={embeddingAvailable}
                   datasetId={datasetId}
                   detail={pick(doc, ['enabled', 'archived', 'id', 'data_source_type', 'doc_form'])}
                   onUpdate={onUpdate}

+ 91 - 81
web/app/components/datasets/hit-testing/index.tsx

@@ -1,13 +1,9 @@
 'use client'
-import React, { useState, FC, useMemo } from 'react'
+import type { FC } from 'react'
+import React, { useMemo, useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import useSWR from 'swr'
-import { fetchTestingRecords } from '@/service/datasets'
 import { omit } from 'lodash-es'
-import Pagination from '@/app/components/base/pagination'
-import Modal from '@/app/components/base/modal'
-import Loading from '@/app/components/base/loading'
-import type { HitTestingResponse, HitTesting } from '@/models/datasets'
 import cn from 'classnames'
 import dayjs from 'dayjs'
 import SegmentCard from '../documents/detail/completed/SegmentCard'
@@ -15,8 +11,13 @@ import docStyle from '../documents/detail/completed/style.module.css'
 import Textarea from './textarea'
 import s from './style.module.css'
 import HitDetail from './hit-detail'
+import type { HitTestingResponse, HitTesting as HitTestingType } from '@/models/datasets'
+import Loading from '@/app/components/base/loading'
+import Modal from '@/app/components/base/modal'
+import Pagination from '@/app/components/base/pagination'
+import { fetchTestingRecords } from '@/service/datasets'
 
-const limit = 10;
+const limit = 10
 
 type Props = {
   datasetId: string
@@ -34,23 +35,23 @@ const RecordsEmpty: FC = () => {
 
 const HitTesting: FC<Props> = ({ datasetId }: Props) => {
   const { t } = useTranslation()
-  const [hitResult, setHitResult] = useState<HitTestingResponse | undefined>(); // 初始化记录为空数组
-  const [submitLoading, setSubmitLoading] = useState(false);
-  const [currParagraph, setCurrParagraph] = useState<{ paraInfo?: HitTesting; showModal: boolean }>({ showModal: false })
-  const [text, setText] = useState('');
+  const [hitResult, setHitResult] = useState<HitTestingResponse | undefined>() // 初始化记录为空数组
+  const [submitLoading, setSubmitLoading] = useState(false)
+  const [currParagraph, setCurrParagraph] = useState<{ paraInfo?: HitTestingType; showModal: boolean }>({ showModal: false })
+  const [text, setText] = useState('')
 
   const [currPage, setCurrPage] = React.useState<number>(0)
   const { data: recordsRes, error, mutate: recordsMutate } = useSWR({
     action: 'fetchTestingRecords',
     datasetId,
-    params: { limit, page: currPage + 1, }
+    params: { limit, page: currPage + 1 },
   }, apiParams => fetchTestingRecords(omit(apiParams, 'action')))
 
   const total = recordsRes?.total || 0
 
-  const points = useMemo(() => (hitResult?.records.map((v) => [v.tsne_position.x, v.tsne_position.y]) || []), [hitResult?.records])
+  const points = useMemo(() => (hitResult?.records.map(v => [v.tsne_position.x, v.tsne_position.y]) || []), [hitResult?.records])
 
-  const onClickCard = (detail: HitTesting) => {
+  const onClickCard = (detail: HitTestingType) => {
     setCurrParagraph({ paraInfo: detail, showModal: true })
   }
 
@@ -71,50 +72,56 @@ const HitTesting: FC<Props> = ({ datasetId }: Props) => {
           text={text}
         />
         <div className={cn(s.title, 'mt-8 mb-2')}>{t('datasetHitTesting.recents')}</div>
-        {!recordsRes && !error ? (
-          <div className='flex-1'><Loading type='app' /></div>
-        ) : recordsRes?.data?.length ? (
-          <>
-            <table className={`w-full border-collapse border-0 mt-3 ${s.table}`}>
-              <thead className="h-8 leading-8 border-b border-gray-200 text-gray-500 font-bold">
-                <tr>
-                  <td className='w-28'>{t('datasetHitTesting.table.header.source')}</td>
-                  <td>{t('datasetHitTesting.table.header.text')}</td>
-                  <td className='w-48'>{t('datasetHitTesting.table.header.time')}</td>
-                </tr>
-              </thead>
-              <tbody className="text-gray-500">
-                {recordsRes?.data?.map((record) => {
-                  return <tr
-                    key={record.id}
-                    className='group border-b border-gray-200 h-8 hover:bg-gray-50 cursor-pointer'
-                    onClick={() => setText(record.content)}
-                  >
-                    <td className='w-24'>
-                      <div className='flex items-center'>
-                        <div className={cn(s[`${record.source}_icon`], s.commonIcon, 'mr-1')} />
-                        <span className='capitalize'>{record.source.replace('_', ' ')}</span>
-                      </div>
-                    </td>
-                    <td className='max-w-xs group-hover:text-primary-600'>{record.content}</td>
-                    <td className='w-36'>
-                      {dayjs.unix(record.created_at).format(t('datasetHitTesting.dateTimeFormat') as string)}
-                    </td>
-                  </tr>
-                })}
-              </tbody>
-            </table>
-            {(total && total > limit)
-              ? <Pagination current={currPage} onChange={setCurrPage} total={total} limit={limit} />
-              : null}
-          </>
-        ) : (
-          <RecordsEmpty />
-        )}
+        {(!recordsRes && !error)
+          ? (
+            <div className='flex-1'><Loading type='app' /></div>
+          )
+          : recordsRes?.data?.length
+            ? (
+              <>
+                <div className='grow overflow-y-auto'>
+                  <table className={`w-full border-collapse border-0 mt-3 ${s.table}`}>
+                    <thead className="sticky top-0 h-8 bg-white leading-8 border-b border-gray-200 text-gray-500 font-bold">
+                      <tr>
+                        <td className='w-28'>{t('datasetHitTesting.table.header.source')}</td>
+                        <td>{t('datasetHitTesting.table.header.text')}</td>
+                        <td className='w-48'>{t('datasetHitTesting.table.header.time')}</td>
+                      </tr>
+                    </thead>
+                    <tbody className="text-gray-500">
+                      {recordsRes?.data?.map((record) => {
+                        return <tr
+                          key={record.id}
+                          className='group border-b border-gray-200 h-8 hover:bg-gray-50 cursor-pointer'
+                          onClick={() => setText(record.content)}
+                        >
+                          <td className='w-24'>
+                            <div className='flex items-center'>
+                              <div className={cn(s[`${record.source}_icon`], s.commonIcon, 'mr-1')} />
+                              <span className='capitalize'>{record.source.replace('_', ' ')}</span>
+                            </div>
+                          </td>
+                          <td className='max-w-xs group-hover:text-primary-600'>{record.content}</td>
+                          <td className='w-36'>
+                            {dayjs.unix(record.created_at).format(t('datasetHitTesting.dateTimeFormat') as string)}
+                          </td>
+                        </tr>
+                      })}
+                    </tbody>
+                  </table>
+                </div>
+                {(total && total > limit)
+                  ? <Pagination current={currPage} onChange={setCurrPage} total={total} limit={limit} />
+                  : null}
+              </>
+            )
+            : (
+              <RecordsEmpty />
+            )}
       </div>
       <div className={s.rightDiv}>
-        {submitLoading ?
-          <div className={s.cardWrapper}>
+        {submitLoading
+          ? <div className={s.cardWrapper}>
             <SegmentCard
               loading={true}
               scene='hitTesting'
@@ -125,33 +132,36 @@ const HitTesting: FC<Props> = ({ datasetId }: Props) => {
               scene='hitTesting'
               className='h-[216px]'
             />
-          </div> : !hitResult?.records.length ? (
-            <div className='h-full flex flex-col justify-center items-center'>
-              <div className={cn(docStyle.commonIcon, docStyle.targetIcon, '!bg-gray-200 !h-14 !w-14')} />
-              <div className='text-gray-300 text-[13px] mt-3'>
-                {t('datasetHitTesting.hit.emptyTip')}
-              </div>
-            </div>
-          ) : (
-            <>
-              <div className='text-gray-600 font-semibold mb-4'>{t('datasetHitTesting.hit.title')}</div>
-              <div className='overflow-auto flex-1'>
-                <div className={s.cardWrapper}>
-                  {hitResult?.records.map((record, idx) => {
-                    return <SegmentCard
-                      key={idx}
-                      loading={false}
-                      detail={record.segment as any}
-                      score={record.score}
-                      scene='hitTesting'
-                      className='h-[216px] mb-4'
-                      onClick={() => onClickCard(record as any)}
-                    />
-                  })}
+          </div>
+          : !hitResult?.records.length
+            ? (
+              <div className='h-full flex flex-col justify-center items-center'>
+                <div className={cn(docStyle.commonIcon, docStyle.targetIcon, '!bg-gray-200 !h-14 !w-14')} />
+                <div className='text-gray-300 text-[13px] mt-3'>
+                  {t('datasetHitTesting.hit.emptyTip')}
                 </div>
               </div>
-            </>
-          )
+            )
+            : (
+              <>
+                <div className='text-gray-600 font-semibold mb-4'>{t('datasetHitTesting.hit.title')}</div>
+                <div className='overflow-auto flex-1'>
+                  <div className={s.cardWrapper}>
+                    {hitResult?.records.map((record, idx) => {
+                      return <SegmentCard
+                        key={idx}
+                        loading={false}
+                        detail={record.segment as any}
+                        score={record.score}
+                        scene='hitTesting'
+                        className='h-[216px] mb-4'
+                        onClick={() => onClickCard(record as any)}
+                      />
+                    })}
+                  </div>
+                </div>
+              </>
+            )
         }
       </div>
       <Modal

+ 59 - 50
web/app/components/datasets/settings/form/index.tsx

@@ -5,6 +5,7 @@ import useSWR from 'swr'
 import { useContext } from 'use-context-selector'
 import { BookOpenIcon } from '@heroicons/react/24/outline'
 import { useTranslation } from 'react-i18next'
+import cn from 'classnames'
 import PermissionsRadio from '../permissions-radio'
 import IndexMethodRadio from '../index-method-radio'
 import { ToastContext } from '@/app/components/base/toast'
@@ -88,7 +89,8 @@ const Form = ({
           <div>{t('datasetSettings.form.name')}</div>
         </div>
         <input
-          className={inputClass}
+          disabled={!currentDataset?.embedding_available}
+          className={cn(inputClass, !currentDataset?.embedding_available && 'opacity-60')}
           value={name}
           onChange={e => setName(e.target.value)}
         />
@@ -99,7 +101,8 @@ const Form = ({
         </div>
         <div>
           <textarea
-            className={`${inputClass} block mb-2 h-[120px] py-2 resize-none`}
+            disabled={!currentDataset?.embedding_available}
+            className={cn(`${inputClass} block mb-2 h-[120px] py-2 resize-none`, !currentDataset?.embedding_available && 'opacity-60')}
             placeholder={t('datasetSettings.form.descPlaceholder') || ''}
             value={description}
             onChange={e => setDescription(e.target.value)}
@@ -116,61 +119,67 @@ const Form = ({
         </div>
         <div className='w-[480px]'>
           <PermissionsRadio
+            disable={!currentDataset?.embedding_available}
             value={permission}
             onChange={v => setPermission(v)}
           />
         </div>
       </div>
-      <div className='w-full h-0 border-b-[0.5px] border-b-gray-200 my-2' />
-      <div className={rowClass}>
-        <div className={labelClass}>
-          <div>{t('datasetSettings.form.indexMethod')}</div>
-        </div>
-        <div className='w-[480px]'>
-          <IndexMethodRadio
-            value={indexMethod}
-            onChange={v => setIndexMethod(v)}
-          />
-        </div>
-      </div>
-      <div className={rowClass}>
-        <div className={labelClass}>
-          <div>{t('datasetSettings.form.embeddingModel')}</div>
-        </div>
-        <div className='w-[480px]'>
-          {currentDataset && (
-            <>
-              <div className='w-full h-9 rounded-lg bg-gray-100 opacity-60'>
-                <ModelSelector
-                  readonly
-                  value={{
-                    providerName: currentDataset.embedding_model_provider as ProviderEnum,
-                    modelName: currentDataset.embedding_model,
-                  }}
-                  modelType={ModelType.embeddings}
-                  onChange={() => {}}
-                />
-              </div>
-              <div className='mt-2 w-full text-xs leading-6 text-gray-500'>
-                {t('datasetSettings.form.embeddingModelTip')}
-                <span className='text-[#155eef] cursor-pointer' onClick={() => setShowSetAPIKeyModal(true)}>{t('datasetSettings.form.embeddingModelTipLink')}</span>
-              </div>
-            </>
-          )}
+      {currentDataset && currentDataset.indexing_technique && (
+        <>
+          <div className='w-full h-0 border-b-[0.5px] border-b-gray-200 my-2' />
+          <div className={rowClass}>
+            <div className={labelClass}>
+              <div>{t('datasetSettings.form.indexMethod')}</div>
+            </div>
+            <div className='w-[480px]'>
+              <IndexMethodRadio
+                disable={!currentDataset?.embedding_available}
+                value={indexMethod}
+                onChange={v => setIndexMethod(v)}
+              />
+            </div>
+          </div>
+        </>
+      )}
+      {currentDataset && currentDataset.indexing_technique === 'high_quality' && (
+        <div className={rowClass}>
+          <div className={labelClass}>
+            <div>{t('datasetSettings.form.embeddingModel')}</div>
+          </div>
+          <div className='w-[480px]'>
+            <div className='w-full h-9 rounded-lg bg-gray-100 opacity-60'>
+              <ModelSelector
+                readonly
+                value={{
+                  providerName: currentDataset.embedding_model_provider as ProviderEnum,
+                  modelName: currentDataset.embedding_model,
+                }}
+                modelType={ModelType.embeddings}
+                onChange={() => {}}
+              />
+            </div>
+            <div className='mt-2 w-full text-xs leading-6 text-gray-500'>
+              {t('datasetSettings.form.embeddingModelTip')}
+              <span className='text-[#155eef] cursor-pointer' onClick={() => setShowSetAPIKeyModal(true)}>{t('datasetSettings.form.embeddingModelTipLink')}</span>
+            </div>
+          </div>
         </div>
-      </div>
-      <div className={rowClass}>
-        <div className={labelClass} />
-        <div className='w-[480px]'>
-          <Button
-            className='min-w-24 text-sm'
-            type='primary'
-            onClick={handleSave}
-          >
-            {t('datasetSettings.form.save')}
-          </Button>
+      )}
+      {currentDataset?.embedding_available && (
+        <div className={rowClass}>
+          <div className={labelClass} />
+          <div className='w-[480px]'>
+            <Button
+              className='min-w-24 text-sm'
+              type='primary'
+              onClick={handleSave}
+            >
+              {t('datasetSettings.form.save')}
+            </Button>
+          </div>
         </div>
-      </div>
+      )}
       {showSetAPIKeyModal && (
         <AccountSetting activeTab="provider" onCancel={async () => {
           setShowSetAPIKeyModal(false)

+ 17 - 1
web/app/components/datasets/settings/index-method-radio/index.module.css

@@ -35,4 +35,20 @@
   border-width: 1.5px;
   border-color: #528BFF;
   box-shadow: 0px 1px 3px rgba(16, 24, 40, 0.1), 0px 1px 2px rgba(16, 24, 40, 0.06);
-}
+}
+
+.wrapper .item.disable {
+  @apply opacity-60;
+}
+.wrapper .item-active.disable {
+  @apply opacity-60;
+}
+.wrapper .item.disable:hover {
+  @apply bg-gray-25 border border-gray-100 shadow-none cursor-default opacity-60;
+}
+.wrapper .item-active.disable:hover {
+  @apply cursor-default opacity-60;
+  border-width: 1.5px;
+  border-color: #528BFF;
+  box-shadow: 0px 1px 3px rgba(16, 24, 40, 0.1), 0px 1px 2px rgba(16, 24, 40, 0.06);
+}

+ 16 - 10
web/app/components/datasets/settings/index-method-radio/index.tsx

@@ -2,7 +2,7 @@
 import { useTranslation } from 'react-i18next'
 import classNames from 'classnames'
 import s from './index.module.css'
-import { DataSet } from '@/models/datasets'
+import type { DataSet } from '@/models/datasets'
 
 const itemClass = `
   w-[234px] p-3 rounded-xl bg-gray-25 border border-gray-100 cursor-pointer
@@ -13,11 +13,13 @@ const radioClass = `
 type IIndexMethodRadioProps = {
   value?: DataSet['indexing_technique']
   onChange: (v?: DataSet['indexing_technique']) => void
+  disable?: boolean
 }
 
 const IndexMethodRadio = ({
   value,
-  onChange
+  onChange,
+  disable,
 }: IIndexMethodRadioProps) => {
   const { t } = useTranslation()
   const options = [
@@ -25,28 +27,32 @@ const IndexMethodRadio = ({
       key: 'high_quality',
       text: t('datasetSettings.form.indexMethodHighQuality'),
       desc: t('datasetSettings.form.indexMethodHighQualityTip'),
-      icon: 'high-quality'
+      icon: 'high-quality',
     },
     {
       key: 'economy',
       text: t('datasetSettings.form.indexMethodEconomy'),
       desc: t('datasetSettings.form.indexMethodEconomyTip'),
-      icon: 'economy'
-    }
+      icon: 'economy',
+    },
   ]
 
   return (
     <div className={classNames(s.wrapper, 'flex justify-between w-full')}>
       {
         options.map(option => (
-          <div 
-            key={option.key} 
+          <div
+            key={option.key}
             className={classNames(
-              option.key === value && s['item-active'], 
+              itemClass,
               s.item,
-              itemClass
+              option.key === value && s['item-active'],
+              disable && s.disable,
             )}
-            onClick={() => onChange(option.key as DataSet['indexing_technique'])}
+            onClick={() => {
+              if (!disable)
+                onChange(option.key as DataSet['indexing_technique'])
+            }}
           >
             <div className='flex items-center mb-1'>
               <div className={classNames(s.icon, s[`${option.icon}-icon`])} />

+ 16 - 0
web/app/components/datasets/settings/permissions-radio/index.module.css

@@ -27,4 +27,20 @@
   border-width: 1.5px;
   border-color: #528BFF;
   box-shadow: 0px 1px 3px rgba(16, 24, 40, 0.1), 0px 1px 2px rgba(16, 24, 40, 0.06);
+}
+
+.wrapper .item.disable {
+  @apply opacity-60;
+}
+.wrapper .item-active.disable {
+  @apply opacity-60;
+}
+.wrapper .item.disable:hover {
+  @apply bg-gray-25 border border-gray-100 shadow-none cursor-default opacity-60;
+}
+.wrapper .item-active.disable:hover {
+  @apply cursor-default opacity-60;
+  border-width: 1.5px;
+  border-color: #528BFF;
+  box-shadow: 0px 1px 3px rgba(16, 24, 40, 0.1), 0px 1px 2px rgba(16, 24, 40, 0.06);
 }

+ 17 - 11
web/app/components/datasets/settings/permissions-radio/index.tsx

@@ -2,7 +2,7 @@
 import { useTranslation } from 'react-i18next'
 import classNames from 'classnames'
 import s from './index.module.css'
-import { DataSet } from '@/models/datasets'
+import type { DataSet } from '@/models/datasets'
 
 const itemClass = `
   flex items-center w-[234px] h-12 px-3 rounded-xl bg-gray-25 border border-gray-100 cursor-pointer
@@ -13,36 +13,42 @@ const radioClass = `
 type IPermissionsRadioProps = {
   value?: DataSet['permission']
   onChange: (v?: DataSet['permission']) => void
+  disable?: boolean
 }
 
 const PermissionsRadio = ({
   value,
-  onChange
+  onChange,
+  disable,
 }: IPermissionsRadioProps) => {
   const { t } = useTranslation()
   const options = [
     {
       key: 'only_me',
-      text: t('datasetSettings.form.permissionsOnlyMe')
+      text: t('datasetSettings.form.permissionsOnlyMe'),
     },
     {
       key: 'all_team_members',
-      text: t('datasetSettings.form.permissionsAllMember')
-    }
+      text: t('datasetSettings.form.permissionsAllMember'),
+    },
   ]
 
   return (
     <div className={classNames(s.wrapper, 'flex justify-between w-full')}>
       {
         options.map(option => (
-          <div 
-            key={option.key} 
+          <div
+            key={option.key}
             className={classNames(
-              option.key === value && s['item-active'], 
-              itemClass, 
-              s.item
+              itemClass,
+              s.item,
+              option.key === value && s['item-active'],
+              disable && s.disable,
             )}
-            onClick={() => onChange(option.key as DataSet['permission'])}
+            onClick={() => {
+              if (!disable)
+                onChange(option.key as DataSet['permission'])
+            }}
           >
             <div className={classNames(s['user-icon'], 'mr-3')} />
             <div className='grow text-sm text-gray-900'>{option.text}</div>

+ 3 - 1
web/i18n/i18next-config.ts

@@ -35,6 +35,8 @@ import exploreEn from './lang/explore.en'
 import exploreZh from './lang/explore.zh'
 import { getLocaleOnClient } from '@/i18n/client'
 
+const localLng = getLocaleOnClient()
+
 const resources = {
   'en': {
     translation: {
@@ -86,7 +88,7 @@ i18n.use(initReactI18next)
   // init i18next
   // for all options read: https://www.i18next.com/overview/configuration-options
   .init({
-    lng: getLocaleOnClient(),
+    lng: localLng,
     fallbackLng: 'en',
     // debug: true,
     resources,