Explorar el Código

New search page (#509)

* initial search page

Signed-off-by: shanghaikid <jiangruiyi@gmail.com>

* search page ui part1

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* new search ui part2

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* stash

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* stash

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* stash

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* stash

Signed-off-by: shanghaikid <jiangruiyi@gmail.com>

* stash

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* stash

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* stash

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* stash

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* stash

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* stash

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* fix empty vector value

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* fix string problem

* supprt focus border

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* keep UI expaned on tab changes

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* fix style issue

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* update package

Signed-off-by: shanghaikid <jiangruiyi@gmail.com>

* support memorize search params in the tree page

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* expand input box if only one vector field

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* upgrade styles

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* update search button

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* adjust style

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* stash

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* stash

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* search api part1

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* store search resutls

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* fix style issue

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* support binary vector

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* remove search stats, if collection is deleted

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* fix load/release change for search page

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* stash

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* add reset

Signed-off-by: shanghaikid <jiangruiyi@gmail.com>

* support export

Signed-off-by: shanghaikid <jiangruiyi@gmail.com>

* stash

Signed-off-by: shanghaikid <jiangruiyi@gmail.com>

* display latency

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* add global filter

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* finish filter bar

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* update

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* update disable tooltip

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* stash

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* rerank part1

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* update search button text

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* fix a bug

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* update style

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* fix rerank bug

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* fix rerank

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* style update

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* adjust style

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* finish rerank

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* update

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* fix index inputs

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* fix bug

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

---------

Signed-off-by: shanghaikid <jiangruiyi@gmail.com>
Signed-off-by: ryjiang <jiangruiyi@gmail.com>
ryjiang hace 11 meses
padre
commit
7d333ea26c
Se han modificado 37 ficheros con 1927 adiciones y 113 borrados
  1. 3 0
      client/package.json
  2. 7 2
      client/src/components/advancedSearch/Filter.tsx
  3. 21 3
      client/src/components/customButton/CustomButton.tsx
  4. 18 8
      client/src/components/customInput/CustomInput.tsx
  5. 2 1
      client/src/components/customInput/Types.ts
  6. 1 1
      client/src/components/customSelector/Types.ts
  7. 16 0
      client/src/components/icons/Icons.tsx
  8. 2 1
      client/src/components/icons/Types.ts
  9. 18 1
      client/src/consts/Milvus.ts
  10. 1 0
      client/src/context/Types.ts
  11. 1 0
      client/src/hooks/Form.ts
  12. 1 0
      client/src/hooks/Pagination.ts
  13. 1 0
      client/src/i18n/cn/button.ts
  14. 1 0
      client/src/i18n/cn/collection.ts
  15. 4 0
      client/src/i18n/cn/search.ts
  16. 1 0
      client/src/i18n/en/button.ts
  17. 1 0
      client/src/i18n/en/collection.ts
  18. 4 0
      client/src/i18n/en/search.ts
  19. 127 2
      client/src/pages/databases/Databases.tsx
  20. 11 4
      client/src/pages/databases/collections/StatusAction.tsx
  21. 558 0
      client/src/pages/databases/collections/search/Search.tsx
  22. 179 0
      client/src/pages/databases/collections/search/SearchGlobalParams.tsx
  23. 191 0
      client/src/pages/databases/collections/search/Styles.ts
  24. 263 0
      client/src/pages/databases/collections/search/VectorInputBox.tsx
  25. 35 0
      client/src/pages/databases/types.ts
  26. 55 63
      client/src/pages/search/SearchParams.tsx
  27. 1 1
      client/src/pages/search/Styles.ts
  28. 7 6
      client/src/pages/search/Types.ts
  29. 3 3
      client/src/pages/search/VectorSearch.tsx
  30. 1 12
      client/src/types/SearchTypes.ts
  31. 88 0
      client/src/utils/Format.ts
  32. 12 0
      client/src/utils/Insert.ts
  33. 133 0
      client/yarn.lock
  34. 8 0
      package.json
  35. 1 5
      server/src/collections/collections.controller.ts
  36. 1 0
      server/src/types/index.ts
  37. 150 0
      yarn.lock

+ 3 - 0
client/package.json

@@ -6,6 +6,8 @@
   "bugs": "https://github.com/zilliztech/attu/issues",
   "private": true,
   "dependencies": {
+    "@codemirror/lang-javascript": "^6.2.2",
+    "@codemirror/state": "^6.4.1",
     "@date-io/dayjs": "1.x",
     "@json2csv/plainjs": "^7.0.3",
     "@material-ui/core": "4.12.4",
@@ -14,6 +16,7 @@
     "@material-ui/pickers": "^3.3.10",
     "@mui/x-data-grid": "^4.0.0",
     "axios": "^1.6.2",
+    "codemirror": "^6.0.1",
     "d3": "^7.8.5",
     "dayjs": "^1.11.9",
     "file-saver": "^2.0.5",

+ 7 - 2
client/src/components/advancedSearch/Filter.tsx

@@ -6,6 +6,7 @@ import {
   Chip,
   Tooltip,
 } from '@material-ui/core';
+import { useTranslation } from 'react-i18next';
 import icons from '@/components/icons/Icons';
 import { generateIdByHash } from '@/utils/Common';
 import AdvancedDialog from './Dialog';
@@ -26,6 +27,9 @@ const Filter = forwardRef((props: FilterProps, ref) => {
   } = props;
   const classes = useStyles();
 
+  // i18n
+  const { t: searchTrans } = useTranslation('search');
+
   const [open, setOpen] = useState(false);
   const [flatConditions, setFlatConditions] = useState<any[]>([]);
   const [initConditions, setInitConditions] = useState<any[]>([]);
@@ -300,7 +304,8 @@ const Filter = forwardRef((props: FilterProps, ref) => {
           disabled={filterDisabled}
           className={classes.afBtn}
           onClick={handleClickOpen}
-          startIcon={<FilterIcon />}
+          size='small'
+          endIcon={<FilterIcon />}
         >
           {showTitle ? title : ''}
         </CustomButton>
@@ -326,7 +331,7 @@ const Filter = forwardRef((props: FilterProps, ref) => {
             onCancel={handleCancel}
             onSubmit={handleSubmit}
             onReset={handleReset}
-            title="Advanced Filter"
+            title={searchTrans('filterExpr')}
             fields={fields}
             handleConditions={handleConditions}
             conditions={flatConditions}

+ 21 - 3
client/src/components/customButton/CustomButton.tsx

@@ -38,9 +38,27 @@ const buttonStyle = makeStyles(theme => ({
 }));
 
 // props types same as Material Button
-const CustomButton = (props: ButtonProps & { tooltip?: string }) => {
+const CustomButton = (
+  props: ButtonProps & {
+    tooltip?: string;
+    tooltipPlacement?:
+      | 'bottom'
+      | 'left'
+      | 'right'
+      | 'top'
+      | 'bottom-end'
+      | 'bottom-start'
+      | 'left-end'
+      | 'left-start'
+      | 'right-end'
+      | 'right-start'
+      | 'top-end'
+      | 'top-start'
+      | undefined;
+  }
+) => {
   const classes = buttonStyle();
-  const { tooltip, ...otherProps } = props;
+  const { tooltip, tooltipPlacement, ...otherProps } = props;
 
   return (
     <>
@@ -49,7 +67,7 @@ const CustomButton = (props: ButtonProps & { tooltip?: string }) => {
       see https://material-ui.com/zh/components/tooltips/#disabled-elements
       */}
       {tooltip ? (
-        <Tooltip title={tooltip}>
+        <Tooltip title={tooltip} placement={tooltipPlacement}>
           <span>
             <Button
               classes={{

+ 18 - 8
client/src/components/customInput/CustomInput.tsx

@@ -49,7 +49,13 @@ const handleOnChange = (param: IChangeParam) => {
     key,
     param: { cb, checkValid, validations },
   } = param;
-  const input = event.target.value;
+  let input = event.target.value;
+
+  // fix for number input
+  if (!isNaN(input)) {
+    input = parseFloat(input);
+  }
+
   const isValid = validations
     ? checkValid({
         key,
@@ -119,7 +125,11 @@ const getAdornmentInput = (
         }}
         endAdornment={
           <InputAdornment position="end">
-            <IconButton onClick={onIconClick || (() => {})} edge="end" role="icon-button">
+            <IconButton
+              onClick={onIconClick || (() => {})}
+              edge="end"
+              role="icon-button"
+            >
               {isPasswordType
                 ? showPassword
                   ? Icons.visible({ classes: { root: classes.icon } })
@@ -129,7 +139,7 @@ const getAdornmentInput = (
           </InputAdornment>
         }
         inputProps={{
-          'role': 'textbox',
+          role: 'textbox',
           'data-cy': key,
         }}
       />
@@ -205,6 +215,7 @@ const getTextfield = (
 
   const info = validInfo ? validInfo[key] : null;
   const defaultInputProps = { 'data-cy': key };
+
   return (
     <TextField
       {...(others as
@@ -219,18 +230,17 @@ const getTextfield = (
           ? { ...inputProps, ...defaultInputProps, role: 'textbox' }
           : { ...defaultInputProps, role: 'textbox' }
       }
-      error={info?.result && info.errText !== ''}
+      error={info?.result === false && info.errText !== ''}
       InputProps={InputProps ? { ...InputProps } : {}}
       helperText={
-        info && info.result && info.errText
-          ? createHelperTextNode(info.errText)
-          : ' '
+        info && info.errText ? createHelperTextNode(info.errText) : ' '
       }
       className={className || ''}
       onBlur={event => {
         handleOnBlur({ event, key, param });
       }}
-      // value={value}
+      type={others.type || 'text'}
+      value={value}
       onChange={event => {
         handleOnChange({
           event,

+ 2 - 1
client/src/components/customInput/Types.ts

@@ -39,7 +39,6 @@ export interface ICustomInputProps {
   iconConfig?: IIconConfig;
   adornmentConfig?: IAdornmentConfig;
   textConfig?: ITextfieldConfig;
-
   // used for validation
   checkValid?: Function;
   validInfo?: IValidInfo;
@@ -79,6 +78,7 @@ export interface ITextfieldConfig {
   type?: string;
   onBlur?: (event: any) => void;
   onChange?: (event: any) => void;
+  onKeyDown?: (event: any) => void;
   InputLabelProps?: Partial<InputLabelProps>;
 }
 
@@ -93,6 +93,7 @@ export interface IAdornmentConfig {
   onIconClick?: () => void;
   onInputBlur?: (event: any) => void;
   onInputChange?: (event: any) => void;
+  onKeyDown?: (event: any) => void;
 }
 
 export type SearchType = {

+ 1 - 1
client/src/components/customSelector/Types.ts

@@ -30,6 +30,6 @@ export interface ICustomGroupSelect {
   haveLabel?: boolean;
   label?: string;
   placeholder?: string;
-  value: string | number;
+  value: any;
   onChange: (event: any) => void;
 }

+ 16 - 0
client/src/components/icons/Icons.tsx

@@ -793,6 +793,22 @@ const icons: { [x in IconsType]: (props?: any) => React.ReactElement } = {
       ></path>
     </SvgIcon>
   ),
+  magic: (props = {}) => (
+    <svg
+      width="15"
+      height="15"
+      viewBox="0 0 15 15"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg"
+    >
+      <path
+        d="M13.9 0.499976C13.9 0.279062 13.7209 0.0999756 13.5 0.0999756C13.2791 0.0999756 13.1 0.279062 13.1 0.499976V1.09998H12.5C12.2791 1.09998 12.1 1.27906 12.1 1.49998C12.1 1.72089 12.2791 1.89998 12.5 1.89998H13.1V2.49998C13.1 2.72089 13.2791 2.89998 13.5 2.89998C13.7209 2.89998 13.9 2.72089 13.9 2.49998V1.89998H14.5C14.7209 1.89998 14.9 1.72089 14.9 1.49998C14.9 1.27906 14.7209 1.09998 14.5 1.09998H13.9V0.499976ZM11.8536 3.14642C12.0488 3.34168 12.0488 3.65826 11.8536 3.85353L10.8536 4.85353C10.6583 5.04879 10.3417 5.04879 10.1465 4.85353C9.9512 4.65827 9.9512 4.34169 10.1465 4.14642L11.1464 3.14643C11.3417 2.95116 11.6583 2.95116 11.8536 3.14642ZM9.85357 5.14642C10.0488 5.34168 10.0488 5.65827 9.85357 5.85353L2.85355 12.8535C2.65829 13.0488 2.34171 13.0488 2.14645 12.8535C1.95118 12.6583 1.95118 12.3417 2.14645 12.1464L9.14646 5.14642C9.34172 4.95116 9.65831 4.95116 9.85357 5.14642ZM13.5 5.09998C13.7209 5.09998 13.9 5.27906 13.9 5.49998V6.09998H14.5C14.7209 6.09998 14.9 6.27906 14.9 6.49998C14.9 6.72089 14.7209 6.89998 14.5 6.89998H13.9V7.49998C13.9 7.72089 13.7209 7.89998 13.5 7.89998C13.2791 7.89998 13.1 7.72089 13.1 7.49998V6.89998H12.5C12.2791 6.89998 12.1 6.72089 12.1 6.49998C12.1 6.27906 12.2791 6.09998 12.5 6.09998H13.1V5.49998C13.1 5.27906 13.2791 5.09998 13.5 5.09998ZM8.90002 0.499976C8.90002 0.279062 8.72093 0.0999756 8.50002 0.0999756C8.2791 0.0999756 8.10002 0.279062 8.10002 0.499976V1.09998H7.50002C7.2791 1.09998 7.10002 1.27906 7.10002 1.49998C7.10002 1.72089 7.2791 1.89998 7.50002 1.89998H8.10002V2.49998C8.10002 2.72089 8.2791 2.89998 8.50002 2.89998C8.72093 2.89998 8.90002 2.72089 8.90002 2.49998V1.89998H9.50002C9.72093 1.89998 9.90002 1.72089 9.90002 1.49998C9.90002 1.27906 9.72093 1.09998 9.50002 1.09998H8.90002V0.499976Z"
+        fill="currentColor"
+        fillRule="evenodd"
+        clipRule="evenodd"
+      ></path>
+    </svg>
+  ),
 };
 
 export default icons;

+ 2 - 1
client/src/components/icons/Types.ts

@@ -50,4 +50,5 @@ export type IconsType =
   | 'question'
   | 'check'
   | 'discord'
-  | 'star';
+  | 'star'
+  | 'magic';

+ 18 - 1
client/src/consts/Milvus.ts

@@ -120,7 +120,8 @@ export type searchKeywordsType =
   | 'search_list'
   | 'radius'
   | 'range_filter'
-  | 'drop_ratio_search';
+  | 'drop_ratio_search'
+  | 'filter';
 
 export type indexConfigType = {
   [x: string]: {
@@ -296,6 +297,11 @@ export enum ConsistencyLevelEnum {
   Customized = 'Customized', // Users pass their own `guarantee_timestamp`.
 }
 
+export const TOP_K_OPTIONS = [50, 100, 150, 200, 250].map(v => ({
+  value: v,
+  label: String(v),
+}));
+
 export const CONSISTENCY_LEVEL_OPTIONS = [
   {
     value: ConsistencyLevelEnum.Bounded,
@@ -315,6 +321,17 @@ export const CONSISTENCY_LEVEL_OPTIONS = [
   },
 ];
 
+export const RERANKER_OPTIONS = [
+  {
+    label: 'RRF',
+    value: 'rrf',
+  },
+  {
+    label: 'Weighted',
+    value: 'weighted',
+  },
+];
+
 export enum DataTypeStringEnum {
   Bool = 'Bool',
   Int8 = 'Int8',

+ 1 - 0
client/src/context/Types.ts

@@ -104,6 +104,7 @@ export type DataContextType = {
   setDatabase: Dispatch<SetStateAction<string>>;
   databases: DatabaseObject[];
   setDatabaseList: Dispatch<SetStateAction<DatabaseObject[]>>;
+  // search UI state
 
   // APIs
   // databases

+ 1 - 0
client/src/hooks/Form.ts

@@ -71,6 +71,7 @@ export const useFormValidation = (form: IForm[]): IValidationInfo => {
         extraParam: rule.extraParam,
         rule: rule.rule,
       });
+
       if (!checkResult) {
         validDetail = {
           result: true,

+ 1 - 0
client/src/hooks/Pagination.ts

@@ -44,6 +44,7 @@ export const usePaginationHook = (list: any[]) => {
   return {
     offset,
     currentPage,
+    setCurrentPage,
     pageSize,
     handlePageSize,
     handleCurrentPage,

+ 1 - 0
client/src/i18n/cn/button.ts

@@ -5,6 +5,7 @@ const btnTrans = {
   reset: '重置',
   update: '更新',
   search: '搜索',
+  searchMulti: `搜索{{number}}`,
   confirm: '确认',
   connect: '连接',
   import: '导入',

+ 1 - 0
client/src/i18n/cn/collection.ts

@@ -84,6 +84,7 @@ const collectionTrans = {
   partitionTab: '分区',
   overviewTab: '概览',
   schemaTab: 'Schema',
+  searchTab: '向量搜索',
   dataTab: '数据',
   previewTab: '数据预览',
   segmentsTab: '数据段(Segments)',

+ 4 - 0
client/src/i18n/cn/search.ts

@@ -20,6 +20,10 @@ const searchTrans = {
   addCondition: '添加条件',
   filterExpr: '过滤表达式',
   exprHelper: '表达式助手',
+  loadCollectionFirst: '请先加载Collection.',
+  noVectorToSearch: '没有用于搜索的向量数据.',
+  noSelectedVectorField: '至少选择一个向量字段进行搜索.',
+  rerank: '排序器',
 };
 
 export default searchTrans;

+ 1 - 0
client/src/i18n/en/button.ts

@@ -5,6 +5,7 @@ const btnTrans = {
   reset: 'Reset',
   update: 'Update',
   search: 'Search',
+  searchMulti: `Search{{number}}`,
   confirm: 'Confirm',
   connect: 'Connect',
   import: 'Import',

+ 1 - 0
client/src/i18n/en/collection.ts

@@ -86,6 +86,7 @@ const collectionTrans = {
   partitionTab: 'Partitions',
   overviewTab: 'Overview',
   schemaTab: 'Schema',
+  searchTab: 'Vector Search',
   dataTab: 'Data',
   previewTab: 'Data Preview',
   segmentsTab: 'Segments',

+ 4 - 0
client/src/i18n/en/search.ts

@@ -20,6 +20,10 @@ const searchTrans = {
   addCondition: 'Add Condition',
   filterExpr: 'Filter expression, eg: id > 0 ',
   exprHelper: 'Expr Helper',
+  loadCollectionFirst: 'Please load the collection first.',
+  noVectorToSearch: 'No vector data to search.',
+  noSelectedVectorField: 'At least select one vector field to search.',
+  rerank: 'Reranker',
 };
 
 export default searchTrans;

+ 127 - 2
client/src/pages/databases/Databases.tsx

@@ -1,4 +1,4 @@
-import { useContext } from 'react';
+import { useContext, useEffect, useState } from 'react';
 import { useParams } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { makeStyles, Theme } from '@material-ui/core';
@@ -11,11 +11,14 @@ import Partitions from './collections/partitions/Partitions';
 import Overview from './collections/overview/Overview';
 import Data from './collections/data/CollectionData';
 import Segments from './collections/segments/Segments';
+import Search from './collections/search/Search';
 import { dataContext, authContext } from '@/context';
 import Collections from './collections/Collections';
 import StatusIcon, { LoadingType } from '@/components/status/StatusIcon';
+import { ConsistencyLevelEnum } from '@/consts';
 import RefreshButton from './RefreshButton';
 import CopyButton from '@/components/advancedSearch/CopyButton';
+import { SearchParams } from './types';
 import { CollectionObject } from '@server/types';
 
 const useStyles = makeStyles((theme: Theme) => ({
@@ -54,6 +57,86 @@ const Databases = () => {
   const { database, collections, loading, fetchCollection } =
     useContext(dataContext);
 
+  // UI state
+  const [searchParams, setSearchParams] = useState<SearchParams[]>(
+    [] as SearchParams[]
+  );
+
+  // init search params
+  useEffect(() => {
+    collections.forEach(c => {
+      // find search params for the collection
+      const searchParam = searchParams.find(
+        s => s.collection.collection_name === c.collection_name
+      );
+
+      // if search params not found, and the schema is ready, create new search params
+      if (!searchParam && c.schema) {
+        setSearchParams(prevParams => {
+          return [
+            ...prevParams,
+            {
+              collection: c,
+              searchParams: c.schema.vectorFields.map(v => {
+                return {
+                  anns_field: v.name,
+                  params: {},
+                  data: '',
+                  expanded: c.schema.vectorFields.length === 1,
+                  field: v,
+                  selected: c.schema.vectorFields.length === 1,
+                };
+              }),
+              globalParams: {
+                topK: 50,
+                consistency_level: ConsistencyLevelEnum.Bounded,
+                filter: '',
+                rerank: 'rrf',
+                rrfParams: { k: 60 },
+                weightedParams: {
+                  weights: Array(c.schema.vectorFields.length).fill(0.5),
+                },
+              },
+              searchResult: null,
+              searchLatency: 0,
+            },
+          ];
+        });
+      } else {
+        // update collection
+        setSearchParams(prevParams => {
+          return prevParams.map(s => {
+            if (s.collection.collection_name === c.collection_name) {
+              // update field in search params
+              const searchParams = s.searchParams.map(sp => {
+                const field = c.schema?.vectorFields.find(
+                  v => v.name === sp.anns_field
+                );
+                if (field) {
+                  return { ...sp, field };
+                }
+                return sp;
+              });
+              // update collection
+              const collection = c;
+              return { ...s, searchParams, collection };
+            }
+            return s;
+          });
+        });
+      }
+    });
+
+    // delete search params for the collection that is not in the collections
+    setSearchParams(prevParams => {
+      return prevParams.filter(s =>
+        collections.find(
+          c => c.collection_name === s.collection.collection_name
+        )
+      );
+    });
+  }, [collections]);
+
   // get current collection from url
   const params = useParams();
   const {
@@ -86,6 +169,19 @@ const Databases = () => {
     ),
   });
 
+  const setCollectionSearchParams = (params: SearchParams) => {
+    setSearchParams(prevParams => {
+      return prevParams.map(s => {
+        if (
+          s.collection.collection_name === params.collection.collection_name
+        ) {
+          return { ...params };
+        }
+        return s;
+      });
+    });
+  };
+
   // render
   return (
     <section className={`page-wrapper ${classes.wrapper}`}>
@@ -109,6 +205,12 @@ const Databases = () => {
           collectionPage={collectionPage}
           collectionName={collectionName}
           tabClass={classes.tab}
+          searchParams={
+            searchParams.find(
+              s => s.collection.collection_name === collectionName
+            )!
+          }
+          setSearchParams={setCollectionSearchParams}
           collections={collections}
         />
       )}
@@ -147,13 +249,24 @@ const CollectionTabs = (props: {
   collectionName: string; // current collection name
   tabClass: string; // tab class
   collections: CollectionObject[]; // collections
+  searchParams: SearchParams; // search params
+  setSearchParams: (params: SearchParams) => void; // set search params
 }) => {
   // props
-  const { collectionPage, collectionName, tabClass, collections } = props;
+  const {
+    collectionPage,
+    collectionName,
+    tabClass,
+    collections,
+    searchParams,
+    setSearchParams,
+  } = props;
+
   // context
   const { isManaged } = useContext(authContext);
   // i18n
   const { t: collectionTrans } = useTranslation('collection');
+
   // collection tabs
   const collectionTabs: ITab[] = [
     {
@@ -161,6 +274,18 @@ const CollectionTabs = (props: {
       component: <Overview />,
       path: `overview`,
     },
+    {
+      label: collectionTrans('searchTab'),
+      component: (
+        <Search
+          collections={collections}
+          collectionName={collectionName}
+          searchParams={searchParams}
+          setSearchParams={setSearchParams}
+        />
+      ),
+      path: `search`,
+    },
     {
       label: collectionTrans('dataTab'),
       component: (

+ 11 - 4
client/src/pages/databases/collections/StatusAction.tsx

@@ -183,10 +183,17 @@ const StatusAction: FC<StatusActionType> = props => {
               className={classes.extraBtn}
               tooltip={collectionTrans('clickToSearch')}
               onClick={() => {
-                navigate({
-                  pathname: '/search',
-                  search: `?collectionName=${collection.schema.name}`,
-                });
+                // navigate to search page, just replace the current url 'overview' with 'search'
+                // http://localhost:3001/#/databases/default/testNode/overview -> http://localhost:3001/#/databases/default/testNode/search
+                navigate(
+                  window.location.pathname +
+                    window.location.hash
+                      .replace('overview', 'search')
+                      .replace('#/', ''),
+                  {
+                    replace: true,
+                  }
+                );
               }}
             >
               {btnTrans('vectorSearch')}

+ 558 - 0
client/src/pages/databases/collections/search/Search.tsx

@@ -0,0 +1,558 @@
+import { useState, useMemo, ChangeEvent, useCallback } from 'react';
+import {
+  Typography,
+  Accordion,
+  AccordionSummary,
+  AccordionDetails,
+  Checkbox,
+} from '@material-ui/core';
+import { useTranslation } from 'react-i18next';
+import { DataService } from '@/http';
+import Icons from '@/components/icons/Icons';
+import AttuGrid from '@/components/grid/Grid';
+import Filter from '@/components/advancedSearch';
+import EmptyCard from '@/components/cards/EmptyCard';
+import CustomButton from '@/components/customButton/CustomButton';
+import { getLabelDisplayedRows } from '@/pages/search/Utils';
+import { useSearchResult, usePaginationHook } from '@/hooks';
+import { getQueryStyles } from './Styles';
+import SearchGlobalParams from './SearchGlobalParams';
+import VectorInputBox from './VectorInputBox';
+import StatusIcon, { LoadingType } from '@/components/status/StatusIcon';
+import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
+import CustomInput from '@/components/customInput/CustomInput';
+import {
+  formatFieldType,
+  VectorStrToObject,
+  cloneObj,
+  generateVectorsByField,
+  saveCsvAs,
+} from '@/utils';
+import SearchParams from '../../../search/SearchParams';
+import {
+  SearchParams as SearchParamsType,
+  SearchSingleParams,
+  SearchResultView,
+} from '../../types';
+import { DYNAMIC_FIELD } from '@/consts';
+import { ColDefinitionsType } from '@/components/grid/Types';
+import { CollectionObject, CollectionFullObject } from '@server/types';
+
+export interface CollectionDataProps {
+  collectionName: string;
+  collections: CollectionObject[];
+  searchParams: SearchParamsType;
+  setSearchParams: (params: SearchParamsType) => void;
+}
+
+const Search = (props: CollectionDataProps) => {
+  // props
+  const { collections, collectionName, searchParams, setSearchParams } = props;
+  const collection = collections.find(
+    i => i.collection_name === collectionName
+  ) as CollectionFullObject;
+
+  // UI states
+  const [tableLoading, setTableLoading] = useState<boolean>();
+  const [highlightField, setHighlightField] = useState<string>('');
+
+  // translations
+  const { t: searchTrans } = useTranslation('search');
+  const { t: btnTrans } = useTranslation('btn');
+  // classes
+  const classes = getQueryStyles();
+
+  // UI functions
+  const handleExpand = useCallback(
+    (panel: string) => (event: ChangeEvent<{}>, expanded: boolean) => {
+      const s = cloneObj(searchParams);
+      const target = s.searchParams.find((sp: SearchSingleParams) => {
+        return sp.field.name === panel;
+      });
+
+      if (target) {
+        target.expanded = expanded;
+        setSearchParams({ ...s });
+      }
+    },
+    [JSON.stringify(searchParams)]
+  );
+
+  const handleSelect = useCallback(
+    (panel: string) => (event: ChangeEvent<{}>) => {
+      const s = cloneObj(searchParams) as SearchParamsType;
+      const target = s.searchParams.find(sp => {
+        return sp.field.name === panel;
+      });
+      if (target) {
+        target.selected = !target.selected;
+
+        setSearchParams({ ...s });
+      }
+    },
+    [JSON.stringify(searchParams)]
+  );
+
+  // update search params
+  const updateSearchParamCallback = useCallback(
+    (updates: SearchSingleParams, index: number) => {
+      if (
+        JSON.stringify(updates) !==
+        JSON.stringify(searchParams.searchParams[index].params)
+      ) {
+        const s = cloneObj(searchParams);
+        // update the searchParams
+        s.searchParams[index].params = updates;
+        setSearchParams({ ...s });
+      }
+    },
+    [JSON.stringify(searchParams)]
+  );
+
+  // generate random vectors
+  const genRandomVectors = useCallback(() => {
+    const s = cloneObj(searchParams) as SearchParamsType;
+    s.searchParams.forEach((sp: SearchSingleParams) => {
+      sp.data = generateVectorsByField(sp.field) as any;
+    });
+
+    setSearchParams({ ...s });
+  }, [JSON.stringify(searchParams)]);
+
+  // on vector input change, update the search params
+  const onVectorInputChange = useCallback(
+    (anns_field: string, value: string) => {
+      const s = cloneObj(searchParams) as SearchParamsType;
+      const target = s.searchParams.find((sp: SearchSingleParams) => {
+        return sp.anns_field === anns_field;
+      });
+
+      if (value !== target!.data) {
+        target!.data = value;
+        setSearchParams({ ...s });
+      }
+    },
+    [JSON.stringify(searchParams)]
+  );
+
+  // on filter change
+  const onFilterChange = useCallback(
+    (value: string) => {
+      const s = cloneObj(searchParams) as SearchParamsType;
+      s.globalParams.filter = value;
+      setSearchParams({ ...s });
+
+      return s;
+    },
+    [JSON.stringify(searchParams)]
+  );
+
+  // set search result
+  const setSearchResult = useCallback(
+    (props: { results: SearchResultView[]; latency: number }) => {
+      const { results, latency } = props;
+      const s = cloneObj(searchParams) as SearchParamsType;
+      s.searchResult = results;
+      s.searchLatency = latency;
+      setSearchParams({ ...s });
+    },
+    [JSON.stringify(searchParams)]
+  );
+
+  // execute search
+  const onSearchClicked = useCallback(async () => {
+    const data: any = [];
+    const weightedParams: number[] = [];
+
+    searchParams.searchParams.forEach((s, index) => {
+      const formatter =
+        VectorStrToObject[s.field.data_type as keyof typeof VectorStrToObject];
+      if (s.selected) {
+        data.push({
+          anns_field: s.field.name,
+          data: formatter(s.data),
+          params: s.params,
+        });
+        weightedParams.push(
+          searchParams.globalParams.weightedParams.weights[index]
+        );
+      }
+    });
+
+    const params: any = {
+      output_fields: outputFields,
+      limit: searchParams.globalParams.topK,
+      data: data,
+      filter: searchParams.globalParams.filter,
+      consistency_level: searchParams.globalParams.consistency_level,
+    };
+
+    // reranker if exists
+    if (data.length > 1) {
+      if (searchParams.globalParams.rerank === 'rrf') {
+        params.rerank = {
+          strategy: 'rrf',
+          params:  searchParams.globalParams.rrfParams,
+        };
+      }
+      if (searchParams.globalParams.rerank === 'weighted') {
+        params.rerank = {
+          strategy: 'weighted',
+          params: { weights: weightedParams },
+        };
+      }
+    }
+
+    setTableLoading(true);
+    try {
+      const res = await DataService.vectorSearchData(
+        searchParams.collection.collection_name,
+        params
+      );
+
+      setTableLoading(false);
+      setSearchResult(res);
+      // setLatency(res.latency);
+    } catch (err) {
+      setTableLoading(false);
+    }
+  }, [JSON.stringify(searchParams)]);
+
+  const searchResultMemo = useSearchResult(
+    (searchParams && (searchParams.searchResult as SearchResultView[])) || []
+  );
+
+  let primaryKeyField = 'id';
+
+  const outputFields: string[] = useMemo(() => {
+    if (!searchParams || !searchParams.collection) {
+      return [];
+    }
+
+    const s = searchParams.collection.schema!;
+    const _outputFields = s.scalarFields.map(f => f.name);
+
+    if (s.enable_dynamic_field) {
+      _outputFields.push(DYNAMIC_FIELD);
+    }
+
+    return _outputFields;
+  }, [JSON.stringify(searchParams)]);
+
+  const {
+    pageSize,
+    handlePageSize,
+    setCurrentPage,
+    currentPage,
+    handleCurrentPage,
+    total,
+    data: result,
+    order,
+    orderBy,
+    handleGridSort,
+  } = usePaginationHook(searchResultMemo || []);
+
+  // reset
+  const onResetClicked = useCallback(() => {
+    const s = cloneObj(searchParams) as SearchParamsType;
+    s.searchResult = null;
+
+    setSearchParams({ ...s });
+    setCurrentPage(0);
+  }, [JSON.stringify(searchParams), setCurrentPage]);
+
+  const colDefinitions: ColDefinitionsType[] = useMemo(() => {
+    const orderArray = [primaryKeyField, 'id', 'score', ...outputFields];
+
+    return searchParams &&
+      searchParams.searchResult &&
+      searchParams.searchResult.length > 0
+      ? Object.keys(searchParams.searchResult[0])
+          .sort((a, b) => {
+            const indexA = orderArray.indexOf(a);
+            const indexB = orderArray.indexOf(b);
+            return indexA - indexB;
+          })
+          .filter(item => {
+            // if primary key field name is id, don't filter it
+            const invalidItems = primaryKeyField === 'id' ? [] : ['id'];
+            return !invalidItems.includes(item);
+          })
+          .map(key => ({
+            id: key,
+            align: 'left',
+            disablePadding: false,
+            label: key === DYNAMIC_FIELD ? searchTrans('dynamicFields') : key,
+            needCopy: key !== 'score',
+          }))
+      : [];
+  }, [JSON.stringify({ searchParams, outputFields })]);
+
+  // methods
+  const handlePageChange = (e: any, page: number) => {
+    handleCurrentPage(page);
+  };
+
+  // collection is not found or collection full object is not ready
+  if (
+    !searchParams ||
+    (searchParams && searchParams.searchParams.length === 0)
+  ) {
+    return <StatusIcon type={LoadingType.CREATING} />;
+  }
+  const hasVectorIndex = searchParams.collection.schema?.hasVectorIndex;
+  const loaded = searchParams.collection.loaded;
+
+  if (!hasVectorIndex || !loaded) {
+    return (
+      <div className={classes.root}>
+        <EmptyCard
+          wrapperClass={`page-empty-card`}
+          icon={<Icons.load />}
+          text={searchTrans('loadCollectionFirst')}
+        />
+      </div>
+    );
+  }
+
+  // disable search button
+  let disableSearch = false;
+  let disableSearchTooltip = '';
+  // has selected vector fields
+  const selectedFields = searchParams.searchParams.filter(s => s.selected);
+
+  if (selectedFields.length === 0) {
+    disableSearch = true;
+    disableSearchTooltip = searchTrans('noSelectedVectorField');
+  }
+  // has vector data to search
+  const noDataInSelected = selectedFields.some(s => s.data === '');
+
+  if (noDataInSelected) {
+    disableSearch = true;
+    disableSearchTooltip = searchTrans('noVectorToSearch');
+  }
+
+  return (
+    <div className={classes.root}>
+      {collection && (
+        <div className={classes.inputArea}>
+          <div className={classes.accordions}>
+            {searchParams.searchParams.map((s, index: number) => {
+              const field = s.field;
+              return (
+                <Accordion
+                  key={`${collection.collection_name}-${field.name}`}
+                  expanded={s.expanded}
+                  onChange={handleExpand(field.name)}
+                  className={`${classes.accordion} ${
+                    highlightField === field.name && 'highlight'
+                  }`}
+                >
+                  <AccordionSummary
+                    expandIcon={<ExpandMoreIcon />}
+                    aria-controls={`${field.name}-content`}
+                    id={`${field.name}-header`}
+                  >
+                    <div className={classes.checkbox}>
+                      {searchParams.searchParams.length > 1 && (
+                        <Checkbox
+                          size="small"
+                          checked={s.selected}
+                          onChange={handleSelect(field.name)}
+                        />
+                      )}
+                      <div className="label">
+                        <Typography
+                          className={`field-name ${
+                            s.data.length > 0 ? 'bold' : ''
+                          }`}
+                        >
+                          {field.name}
+                        </Typography>
+                        <Typography className="vector-type">
+                          {formatFieldType(field)}
+                          <i>{field.index && field.index.metricType}</i>
+                        </Typography>
+                      </div>
+                    </div>
+                  </AccordionSummary>
+                  <AccordionDetails className={classes.accordionDetail}>
+                    <VectorInputBox
+                      searchParams={s}
+                      onChange={onVectorInputChange}
+                    />
+
+                    <Typography className="text">
+                      {searchTrans('thirdTip')}
+                    </Typography>
+
+                    <SearchParams
+                      wrapperClass="paramsWrapper"
+                      consistency_level={'Strong'}
+                      handleConsistencyChange={(level: string) => {}}
+                      indexType={field.index.indexType}
+                      indexParams={field.index_params}
+                      searchParamsForm={s.params}
+                      handleFormChange={(
+                        updates: { [key in string]: number | string }
+                      ) => {
+                        updateSearchParamCallback(updates as any, index);
+                      }}
+                      topK={searchParams.globalParams.topK}
+                      setParamsDisabled={() => {
+                        return false;
+                      }}
+                    />
+                  </AccordionDetails>
+                </Accordion>
+              );
+            })}
+          </div>
+
+          <div className={classes.searchControls}>
+            <SearchGlobalParams
+              onSlideChange={(field: string) => {
+                setHighlightField(field);
+              }}
+              onSlideChangeCommitted={() => {
+                setHighlightField('');
+              }}
+              searchParams={searchParams}
+              searchGlobalParams={searchParams.globalParams}
+              handleFormChange={(params: any) => {
+                searchParams.globalParams = params;
+                setSearchParams({ ...searchParams });
+              }}
+            />
+
+            <CustomButton
+              onClick={genRandomVectors}
+              size="small"
+              disabled={false}
+            >
+              {btnTrans('example')}
+            </CustomButton>
+
+            <CustomButton
+              variant="contained"
+              size="small"
+              disabled={disableSearch}
+              tooltip={disableSearchTooltip}
+              tooltipPlacement="top"
+              onClick={onSearchClicked}
+            >
+              {btnTrans('searchMulti', {
+                number:
+                  searchParams.collection.schema.vectorFields.length > 1
+                    ? `(${selectedFields.length})`
+                    : '',
+              })}
+            </CustomButton>
+          </div>
+
+          <div className={classes.searchResults}>
+            <section className={classes.toolbar}>
+              <div className="left">
+                <CustomInput
+                  type="text"
+                  textConfig={{
+                    label: searchTrans('filterExpr'),
+                    key: 'advFilter',
+                    className: classes.filterInput,
+                    onChange: onFilterChange,
+                    value: searchParams.globalParams.filter,
+                    disabled: false,
+                    variant: 'filled',
+                    required: false,
+                    InputLabelProps: { shrink: true },
+                    InputProps: {
+                      endAdornment: (
+                        <Filter
+                          title={''}
+                          showTitle={false}
+                          fields={collection.schema.scalarFields}
+                          filterDisabled={false}
+                          onSubmit={(value: string) => {
+                            onFilterChange(value);
+                          }}
+                          showTooltip={false}
+                        />
+                      ),
+                    },
+                    onKeyDown: (e: any) => {
+                      if (e.key === 'Enter') {
+                        e.preventDefault();
+                        onSearchClicked();
+                      }
+                    },
+                  }}
+                  checkValid={() => true}
+                />
+              </div>
+              <div className="right">
+                <CustomButton
+                  className="btn"
+                  disabled={result.length === 0}
+                  onClick={() => {
+                    saveCsvAs(
+                      searchParams.searchResult,
+                      `search_result_${searchParams.collection.collection_name}`
+                    );
+                  }}
+                  startIcon={<Icons.download classes={{ root: 'icon' }} />}
+                >
+                  {btnTrans('export')}
+                </CustomButton>
+                <CustomButton
+                  className="btn"
+                  onClick={onResetClicked}
+                  startIcon={<Icons.clear classes={{ root: 'icon' }} />}
+                >
+                  {btnTrans('reset')}
+                </CustomButton>
+              </div>
+            </section>
+
+            {(searchParams.searchResult &&
+              searchParams.searchResult.length > 0) ||
+            tableLoading ? (
+              <AttuGrid
+                toolbarConfigs={[]}
+                colDefinitions={colDefinitions}
+                rows={result}
+                rowCount={total}
+                primaryKey="rank"
+                page={currentPage}
+                rowHeight={39}
+                onPageChange={handlePageChange}
+                rowsPerPage={pageSize}
+                setRowsPerPage={handlePageSize}
+                openCheckBox={false}
+                isLoading={tableLoading}
+                orderBy={orderBy}
+                order={order}
+                labelDisplayedRows={getLabelDisplayedRows(
+                  `(${searchParams.searchLatency} ms)`
+                )}
+                handleSort={handleGridSort}
+              />
+            ) : (
+              <EmptyCard
+                wrapperClass={`page-empty-card`}
+                icon={<Icons.search />}
+                text={
+                  searchParams.searchResult !== null
+                    ? searchTrans('empty')
+                    : searchTrans('startTip')
+                }
+              />
+            )}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+};
+
+export default Search;

+ 179 - 0
client/src/pages/databases/collections/search/SearchGlobalParams.tsx

@@ -0,0 +1,179 @@
+import { useCallback, ChangeEvent } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Slider } from '@material-ui/core';
+import CustomInput from '@/components/customInput/CustomInput';
+import CustomSelector from '@/components/customSelector/CustomSelector';
+import {
+  CONSISTENCY_LEVEL_OPTIONS,
+  TOP_K_OPTIONS,
+  RERANKER_OPTIONS,
+} from '@/consts';
+import { SearchParams, GlobalParams } from '../../types';
+
+export interface CollectionDataProps {
+  searchGlobalParams: GlobalParams;
+  searchParams: SearchParams;
+  handleFormChange: (form: GlobalParams) => void;
+  onSlideChange: (field: string) => void;
+  onSlideChangeCommitted: () => void;
+}
+
+const SearchGlobalParams = (props: CollectionDataProps) => {
+  // props
+  const {
+    searchParams,
+    searchGlobalParams,
+    handleFormChange,
+    onSlideChange,
+    onSlideChangeCommitted,
+  } = props;
+  const selectedCount = searchParams.searchParams.filter(
+    sp => sp.selected
+  ).length;
+  const showReranker = selectedCount > 1;
+
+  // translations
+  const { t: warningTrans } = useTranslation('warning');
+  const { t: collectionTrans } = useTranslation('collection');
+  const { t: searchTrans } = useTranslation('search');
+
+  // UI functions
+  const handleInputChange = useCallback(
+    <K extends keyof GlobalParams>(key: K, value: GlobalParams[K]) => {
+      let form = { ...searchGlobalParams };
+      if (value === '') {
+        delete form[key];
+      } else {
+        form = { ...searchGlobalParams, [key]: value };
+      }
+
+      handleFormChange(form);
+    },
+    [handleFormChange, searchGlobalParams]
+  );
+
+  const onRerankChanged = useCallback(
+    (e: { target: { value: unknown } }) => {
+      const rerankerStr = e.target.value as 'rrf' | 'weighted';
+
+      handleInputChange('rerank', rerankerStr);
+    },
+    [selectedCount, handleInputChange]
+  );
+
+  return (
+    <>
+      <CustomSelector
+        options={TOP_K_OPTIONS}
+        value={searchGlobalParams.topK}
+        label={collectionTrans('topK')}
+        wrapperClass="selector"
+        variant="filled"
+        onChange={(e: { target: { value: unknown } }) => {
+          const topK = e.target.value as number;
+          handleInputChange('topK', topK);
+        }}
+      />
+      <CustomSelector
+        options={CONSISTENCY_LEVEL_OPTIONS}
+        value={searchGlobalParams.consistency_level}
+        label={collectionTrans('consistency')}
+        wrapperClass="selector"
+        variant="filled"
+        onChange={(e: { target: { value: unknown } }) => {
+          const consistency = e.target.value as string;
+          handleInputChange('consistency_level', consistency);
+        }}
+      />
+
+      {showReranker && (
+        <>
+          <CustomSelector
+            options={RERANKER_OPTIONS}
+            value={
+              searchGlobalParams.rerank
+                ? searchGlobalParams.rerank
+                : RERANKER_OPTIONS[0].value
+            }
+            label={searchTrans('rerank')}
+            wrapperClass="selector"
+            variant="filled"
+            onChange={(e: { target: { value: unknown } }) => {
+              const rerankerStr = e.target.value as 'rrf' | 'weighted';
+
+              handleInputChange('rerank', rerankerStr);
+            }}
+          />
+
+          {searchGlobalParams.rerank == 'rrf' && (
+            <CustomInput
+              type="text"
+              textConfig={{
+                type: 'number',
+                label: 'K',
+                key: 'k',
+                onChange: value => {
+                  handleInputChange('rrfParams', { k: Number(value) });
+                },
+                variant: 'filled',
+                placeholder: 'k',
+                fullWidth: true,
+                validations: [
+                  {
+                    rule: 'require',
+                    errorText: warningTrans('required', {
+                      name: 'k',
+                    }),
+                  },
+                ],
+                defaultValue: 60,
+                value: searchGlobalParams.rrfParams!.k,
+              }}
+              checkValid={() => true}
+            />
+          )}
+
+          {searchGlobalParams.rerank == 'weighted' &&
+            searchParams.searchParams.map((s, index) => {
+              if (s.selected) {
+                return (
+                  <Slider
+                    key={s.anns_field}
+                    color="secondary"
+                    defaultValue={0.5}
+                    value={searchGlobalParams.weightedParams!.weights[index]}
+                    getAriaValueText={value => {
+                      return `${s.anns_field}'s weight: ${value}`;
+                    }}
+                    onChange={(
+                      e: ChangeEvent<{}>,
+                      value: number | number[]
+                    ) => {
+                      // update the selected field
+                      const weights = [
+                        ...searchGlobalParams.weightedParams!.weights,
+                      ];
+                      weights[index] = Number(value);
+                      handleInputChange('weightedParams', { weights: weights });
+                      // fire on change event
+                      onSlideChange(s.anns_field);
+                    }}
+                    onChangeCommitted={() => {
+                      onSlideChangeCommitted();
+                    }}
+                    aria-labelledby="weight-slider"
+                    valueLabelDisplay="auto"
+                    step={0.1}
+                    min={0}
+                    max={1}
+                  />
+                );
+              }
+            })}
+        </>
+      )}
+    </>
+  );
+};
+
+export default SearchGlobalParams;

+ 191 - 0
client/src/pages/databases/collections/search/Styles.ts

@@ -0,0 +1,191 @@
+import { makeStyles, Theme } from '@material-ui/core';
+import { Height } from '@material-ui/icons';
+
+export const getQueryStyles = makeStyles((theme: Theme) => ({
+  root: {
+    display: 'flex',
+    flexDirection: 'column',
+    height: '100%',
+  },
+
+  inputArea: {
+    display: 'flex',
+    flexDirection: 'row',
+    width: '100%',
+    height: 'max-content',
+    padding: 0,
+  },
+
+  accordions: {
+    display: 'flex',
+    width: '220px',
+    flexDirection: 'column',
+    flexShrink: 0,
+    padding: '0 8px 8px 0',
+    borderRadius: '0',
+    minHeight: 'calc(100vh - 164px)',
+    height: 'calc(100vh - 164px)',
+    overflow: 'auto',
+
+    borderRight: `1px solid ${theme.palette.divider}`,
+    '& .MuiAccordion-root.Mui-expanded': {
+      margin: 0,
+    },
+    '& .MuiAccordion-root.Mui-expanded:before': {
+      opacity: 1,
+    },
+  },
+
+  accordion: {
+    borderRadius: 0,
+    boxShadow: 'none',
+    padding: '0',
+    border: '1px solid transparent',
+
+    '&:first-child': {
+      borderTopLeftRadius: 0,
+      borderTopRightRadius: 0,
+    },
+
+    '&.highlight': {
+      border: `1px solid ${theme.palette.secondary.main}`,
+    },
+
+    // borderBottom: `1px solid ${theme.palette.divider}`,
+    '& .MuiAccordionSummary-root': {
+      minHeight: '48px',
+      padding: '0 12px 0 0',
+      '& .MuiAccordionSummary-expandIcon': {
+        padding: 4,
+      },
+    },
+    '& .MuiAccordionSummary-content': {
+      margin: 0,
+      padding: '8px 0',
+    },
+    '& .MuiAccordionSummary-expandIcon': {
+      alignSelf: 'flex-start',
+      position: 'relative',
+      top: '4px',
+    },
+  },
+  accordionDetail: {
+    display: 'flex',
+    flexDirection: 'column',
+    padding: '0',
+    '& .textarea': {
+      height: '100px',
+      marginBottom: '8px',
+    },
+    '& .paramsWrapper': {
+      '& .MuiFormControl-root': {
+        width: '100%',
+      },
+    },
+  },
+  heading: {
+    flexShrink: 0,
+  },
+  checkbox: {
+    display: 'flex',
+    flexDirection: 'row',
+    '& .MuiCheckbox-root': {
+      padding: 0,
+      marginRight: 4,
+      alignSelf: 'flex-start',
+      postion: 'relative',
+      top: '2px',
+    },
+    '& .field-name': {
+      fontSize: '13px',
+      fontWeight: 400,
+      lineHeight: '20px',
+      wordBreak: 'break-all',
+    },
+    '& .bold': {
+      fontWeight: 600,
+    },
+    '& .vector-type': {
+      color: theme.palette.text.secondary,
+      fontSize: '12px',
+      lineHeight: '20px',
+      '& i': {
+        marginLeft: '4px',
+        fontSize: '10px',
+        fontWeight: 600,
+        color: theme.palette.primary.light,
+      },
+    },
+  },
+
+  vectorInputBox: {
+    height: '124px',
+    margin: '0 0 8px 0',
+    overflow: 'auto',
+    backgroundColor: '#f4f4f4',
+    cursor: 'text',
+    boxShadow: '0 1px 0 transparent',
+    transition: `box-shadow 0.3s ease`,
+    '&:hover': {
+      boxShadow: '0 1px 0 #000',
+    },
+    '&:active': {
+      boxShadow: `0 1px 0 ${theme.palette.primary.main}`,
+    },
+    '&.focused': {
+      boxShadow: `0 2px 0 ${theme.palette.primary.main}`,
+    },
+  },
+
+  searchControls: {
+    display: 'flex',
+    flexDirection: 'column',
+    width: 120,
+    minWidth: 120,
+    padding: '0 8px',
+    borderRight: `1px solid ${theme.palette.divider}`,
+
+    '& .selector': {
+      marginBottom: '8px',
+    },
+    '& span button': {
+      width: '100%',
+      height: '100%',
+    },
+  },
+
+  searchResults: {
+    display: 'flex',
+    flexDirection: 'column',
+    flexGrow: 1,
+    padding: '0 8px',
+    overflow: 'auto',
+  },
+
+  toolbar: {
+    display: 'flex',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+    background: '#fff',
+    marginBottom: theme.spacing(1),
+
+    '& .left': {
+      display: 'flex',
+      gap: theme.spacing(1),
+      '& .MuiFilledInput-adornedEnd': {
+        paddingRight: 0,
+      },
+      '& span button': {
+        height: '100%',
+      },
+    },
+    '& .right': {},
+  },
+
+  filterInput: {
+    width: '280px',
+    '& .MuiFormHelperText-root': {
+      display: 'none',
+    },
+  },
+}));

+ 263 - 0
client/src/pages/databases/collections/search/VectorInputBox.tsx

@@ -0,0 +1,263 @@
+import { useRef, useEffect, useState } from 'react';
+import { EditorState } from '@codemirror/state';
+import { EditorView, keymap } from '@codemirror/view';
+import { insertTab } from '@codemirror/commands';
+import { indentUnit } from '@codemirror/language';
+import { minimalSetup } from 'codemirror';
+import { javascript } from '@codemirror/lang-javascript';
+import { linter, Diagnostic } from '@codemirror/lint';
+import { FieldObject } from '@server/types';
+import { DataTypeStringEnum } from '@/consts';
+import { SearchSingleParams } from '../../types';
+import { isSparseVector, transformObjStrToJSONStr } from '@/utils';
+import { getQueryStyles } from './Styles';
+
+const floatVectorValidator = (text: string, field: FieldObject) => {
+  try {
+    const value = JSON.parse(text);
+    const dim = field.dimension;
+    if (!Array.isArray(value)) {
+      return {
+        valid: false,
+        message: `Not an array`,
+      };
+    }
+
+    if (Array.isArray(value) && value.length !== dim) {
+      return {
+        valid: false,
+        value: undefined,
+        message: `Dimension ${value.length} is not equal to ${dim} `,
+      };
+    }
+
+    return { valid: true, message: ``, value: value };
+  } catch (e: any) {
+    return {
+      valid: false,
+      message: `Wrong Float Vector format, it should be an array of ${field.dimension} numbers`,
+    };
+  }
+};
+
+const binaryVectorValidator = (text: string, field: FieldObject) => {
+  try {
+    const value = JSON.parse(text);
+    const dim = field.dimension;
+    if (!Array.isArray(value)) {
+      return {
+        valid: false,
+        message: `Not an array`,
+      };
+    }
+
+    if (Array.isArray(value) && value.length !== dim / 8) {
+      return {
+        valid: false,
+        value: undefined,
+        message: `Dimension ${value.length} is not equal to ${dim / 8} `,
+      };
+    }
+
+    return { valid: true, message: ``, value: value };
+  } catch (e: any) {
+    return {
+      valid: false,
+      message: `Wrong Binary Vector format, it should be an array of ${
+        field.dimension / 8
+      } numbers`,
+    };
+  }
+};
+
+const sparseVectorValidator = (text: string, field: FieldObject) => {
+  if (!isSparseVector(text)) {
+    return {
+      valid: false,
+      value: undefined,
+      message: `Incorrect Sparse Vector format, it should be like {1: 0.1, 3: 0.2}`,
+    };
+  }
+  try {
+    JSON.parse(transformObjStrToJSONStr(text));
+    return {
+      valid: true,
+      message: ``,
+    };
+  } catch (e: any) {
+    return {
+      valid: false,
+      message: `Wrong Sparse Vector format`,
+    };
+  }
+};
+
+const Validator = {
+  [DataTypeStringEnum.FloatVector]: floatVectorValidator,
+  [DataTypeStringEnum.BinaryVector]: binaryVectorValidator,
+  [DataTypeStringEnum.Float16Vector]: floatVectorValidator,
+  [DataTypeStringEnum.BFloat16Vector]: floatVectorValidator,
+  [DataTypeStringEnum.SparseFloatVector]: sparseVectorValidator,
+};
+
+export type VectorInputBoxProps = {
+  onChange: (anns_field: string, value: string) => void;
+  searchParams: SearchSingleParams;
+};
+
+export default function VectorInputBox(props: VectorInputBoxProps) {
+  // props
+  const { searchParams, onChange } = props;
+  const { field, data } = searchParams;
+
+  // UI states
+  const [isFocused, setIsFocused] = useState(false);
+
+  // classes
+  const classes = getQueryStyles();
+
+  // refs
+  const editorEl = useRef<HTMLDivElement>(null);
+  const editor = useRef<EditorView>();
+  const onChangeRef = useRef(onChange);
+  const dataRef = useRef(data);
+  const fieldRef = useRef(field);
+
+  // get validator
+  const validator = Validator[field.data_type as keyof typeof Validator];
+
+  useEffect(() => {
+    // update dataRef and onChangeRef when data changes
+    dataRef.current = data;
+    onChangeRef.current = onChange;
+    fieldRef.current = field;
+
+    if (editor.current) {
+      // only data replace should trigger this, otherwise, let cm handle the state
+      if (editor.current.state.doc.toString() !== data) {
+        editor.current.dispatch({
+          changes: {
+            from: 0,
+            to: editor.current.state.doc.length,
+            insert: data,
+          },
+        });
+      }
+    }
+  }, [JSON.stringify(searchParams)]);
+
+  // create editor
+  useEffect(() => {
+    if (!editor.current) {
+      const startState = EditorState.create({
+        doc: data,
+        extensions: [
+          minimalSetup,
+          javascript(),
+          linter(view => {
+            const text = view.state.doc.toString();
+
+            // ignore empty text
+            if (!text) return [];
+
+            // validate
+            const { valid, message } = validator(text, field);
+
+            // if invalid, draw a red line
+            if (!valid) {
+              let diagnostics: Diagnostic[] = [];
+
+              diagnostics.push({
+                from: 0,
+                to: view.state.doc.line(view.state.doc.lines).to,
+                severity: 'error',
+                message: message,
+                actions: [
+                  {
+                    name: 'Remove',
+                    apply(view, from, to) {
+                      view.dispatch({ changes: { from, to } });
+                    },
+                  },
+                ],
+              });
+
+              return diagnostics;
+            } else {
+              // onChangeRef.current(searchParams.anns_field, value);
+              return [];
+            }
+          }),
+          keymap.of([{ key: 'Tab', run: insertTab }]), // fix tab behaviour
+          indentUnit.of('    '), // fix tab indentation
+          EditorView.theme({
+            '&.cm-editor': {
+              '&.cm-focused': {
+                outline: 'none',
+              },
+            },
+            '.cm-content': {
+              color: '#484D52',
+              fontSize: '12px',
+            },
+            '.cm-gutters': {
+              display: 'none',
+            },
+            '.cm-activeLine': {
+              backgroundColor: '#f4f4f4',
+            },
+            '.cm-tooltip-lint': {
+              width: '80%',
+            },
+          }),
+          EditorView.baseTheme({
+            '&light .cm-selectionBackground': {
+              backgroundColor: '#f4f4f4',
+            },
+            '&light.cm-focused .cm-selectionBackground': {
+              backgroundColor: '#f4f4f4',
+            },
+            '&light .cm-activeLineGutter': {
+              backgroundColor: 'transparent',
+              fontWeight: 'bold',
+            },
+          }),
+          EditorView.lineWrapping,
+          EditorView.updateListener.of(update => {
+            if (update.docChanged) {
+              const text = update.state.doc.toString();
+              const { valid } = validator(text, fieldRef.current);
+              if (valid || text === '') {
+                onChangeRef.current(searchParams.anns_field, text);
+              }
+            }
+            if (update.focusChanged) {
+              setIsFocused(update.view.hasFocus);
+            }
+          }),
+        ],
+      });
+
+      editor.current = new EditorView({
+        state: startState,
+        parent: editorEl.current!,
+      });
+    }
+    return () => {
+      if (editor.current) {
+        editor.current.destroy();
+        editor.current = undefined;
+      }
+    };
+  }, [JSON.stringify(field)]);
+
+  return (
+    <div
+      className={`${classes.vectorInputBox} ${isFocused ? 'focused' : ''}`}
+      ref={editorEl}
+      onClick={() => {
+        if (editor.current) editor.current.focus();
+      }}
+    ></div>
+  );
+}

+ 35 - 0
client/src/pages/databases/types.ts

@@ -0,0 +1,35 @@
+import { FieldObject, CollectionObject, RerankerObj } from '@server/types';
+
+export type SearchSingleParams = {
+  anns_field: string;
+  params: Record<string, any>;
+  data: string;
+  expanded: boolean;
+  selected: boolean;
+  field: FieldObject;
+};
+
+export type GlobalParams = {
+  topK: number;
+  consistency_level: string;
+  filter: string;
+  rerank: 'rrf' | 'weighted';
+  rrfParams: { k: number };
+  weightedParams: { weights: number[] };
+  round_decimal?: number;
+};
+
+export type SearchResultView = {
+  // dynamic field names
+  [key: string]: any;
+  rank: number;
+  distance: number;
+};
+
+export type SearchParams = {
+  collection: CollectionObject;
+  searchParams: SearchSingleParams[];
+  globalParams: GlobalParams;
+  searchResult: SearchResultView[] | null;
+  searchLatency: number;
+};

+ 55 - 63
client/src/pages/search/SearchParams.tsx

@@ -3,14 +3,11 @@ import { FC, useCallback, useContext, useEffect, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import CustomInput from '@/components/customInput/CustomInput';
 import { ITextfieldConfig } from '@/components/customInput/Types';
-import CustomSelector from '@/components/customSelector/CustomSelector';
 import {
   DEFAULT_NLIST_VALUE,
   DEFAULT_SEARCH_PARAM_VALUE_MAP,
   INDEX_CONFIG,
   searchKeywordsType,
-  CONSISTENCY_LEVEL_OPTIONS,
-  ConsistencyLevelEnum,
 } from '@/consts';
 import { rootContext } from '@/context';
 import { useFormValidation } from '@/hooks';
@@ -35,17 +32,14 @@ const getStyles = makeStyles((theme: Theme) => ({
 }));
 
 const SearchParams: FC<SearchParamsProps> = ({
-  indexType,
-  indexParams,
+  indexType = '',
+  indexParams = [],
   searchParamsForm,
   handleFormChange,
-  handleConsistencyChange,
-  consistency_level,
   topK,
   setParamsDisabled,
   wrapperClass = '',
 }) => {
-  const { t: collectionTrans } = useTranslation('collection');
   const { t: warningTrans } = useTranslation('warning');
   const classes = getStyles();
 
@@ -65,23 +59,19 @@ const SearchParams: FC<SearchParamsProps> = ({
         );
     }
 
-    const commonParams: searchKeywordsType[] = [
-      'radius',
-      'range_filter',
-      'round_decimal',
-    ];
+    const commonParams: searchKeywordsType[] = ['radius', 'range_filter'];
     return indexType !== '' && isSupportedType
-      ? [...INDEX_CONFIG[indexType].search, ...commonParams]
+      ? [...INDEX_CONFIG[indexType!].search, ...commonParams]
       : commonParams;
   }, [indexType, openSnackBar, warningTrans]);
 
   const handleInputChange = useCallback(
-    (key: string, value: number | string) => {
+    (key: string, value: number | string | typeof NaN) => {
       let form = { ...searchParamsForm };
-      if (value === '') {
+      if (value === '' || isNaN(value as any)) {
         delete form[key];
       } else {
-        form = { ...searchParamsForm, [key]: Number(value) };
+        form = { ...searchParamsForm, [key]: value };
       }
 
       handleFormChange(form);
@@ -92,7 +82,7 @@ const SearchParams: FC<SearchParamsProps> = ({
   /**
    * function to transfer search params to CustomInput need config type
    */
-  const getNumberInputConfig = useCallback(
+  const getInputConfig = useCallback(
     (params: SearchParamInputConfig): ITextfieldConfig => {
       const {
         label,
@@ -102,6 +92,7 @@ const SearchParams: FC<SearchParamsProps> = ({
         value,
         handleChange,
         isInt = true,
+        type = 'number',
         required = true,
       } = params;
 
@@ -117,7 +108,7 @@ const SearchParams: FC<SearchParamsProps> = ({
         },
         className: classes.inlineInput,
         variant: 'filled',
-        type: 'number',
+        type: type,
         value,
         validations: [],
       };
@@ -136,6 +127,14 @@ const SearchParams: FC<SearchParamsProps> = ({
         });
       }
 
+      if (typeof min === 'number' && typeof max === 'number') {
+        config.validations?.push({
+          rule: 'range',
+          errorText: warningTrans('range', { name: label, min, max }),
+          extraParam: { min, max, type: 'number' },
+        });
+      }
+
       // search_k
       if (isSearchK) {
         config.validations?.push({
@@ -169,10 +168,23 @@ const SearchParams: FC<SearchParamsProps> = ({
       const configParamMap: {
         [key in searchKeywordsType]: SearchParamInputConfig;
       } = {
+        filter: {
+          label: 'filter',
+          key: 'filter',
+          value: searchParamsForm['filter'] ?? '',
+          isInt: false,
+          type: 'text',
+          required: false,
+          handleChange: value => {
+            handleInputChange('filter', value);
+          },
+          className: classes.inlineInput,
+        },
         round_decimal: {
           label: 'round',
           key: 'round_decimal',
-          value: searchParamsForm['round_decimal'] || '',
+          type: 'number',
+          value: searchParamsForm['round_decimal'] ?? '',
           min: -1,
           max: 10,
           isInt: true,
@@ -185,7 +197,8 @@ const SearchParams: FC<SearchParamsProps> = ({
         nprobe: {
           label: 'nprobe',
           key: 'nprobe',
-          value: searchParamsForm['nprobe'] || '',
+          type: 'number',
+          value: searchParamsForm['nprobe'] ?? '',
           min: 1,
           max: nlist,
           isInt: true,
@@ -197,12 +210,12 @@ const SearchParams: FC<SearchParamsProps> = ({
         radius: {
           label: 'radius',
           key: 'radius',
-          value: searchParamsForm['radius'] || '',
-          min: 1,
-          max: nlist,
+          type: 'number',
+          value: searchParamsForm['radius'] ?? '',
           isInt: false,
           required: false,
           handleChange: value => {
+            console.log(value, typeof value);
             handleInputChange('radius', value);
           },
           className: classes.inlineInput,
@@ -210,11 +223,10 @@ const SearchParams: FC<SearchParamsProps> = ({
         range_filter: {
           label: 'range filter',
           key: 'range_filter',
-          value: searchParamsForm['range_filter'] || '',
-          min: topK,
-          max: Infinity,
+          value: searchParamsForm['range_filter'] ?? '',
           isInt: false,
           required: false,
+          type: 'number',
           handleChange: value => {
             handleInputChange('range_filter', value);
           },
@@ -223,10 +235,9 @@ const SearchParams: FC<SearchParamsProps> = ({
         ef: {
           label: 'ef',
           key: 'ef',
-          value: searchParamsForm['ef'] || '',
-          min: topK,
-          max: 32768,
+          value: searchParamsForm['ef'] ?? '',
           isInt: true,
+          type: 'number',
           handleChange: value => {
             handleInputChange('ef', value);
           },
@@ -234,10 +245,12 @@ const SearchParams: FC<SearchParamsProps> = ({
         level: {
           label: 'level',
           key: 'level',
-          value: searchParamsForm['level'] || '',
+          value: searchParamsForm['level'] ?? 1,
           min: 1,
           max: 3,
           isInt: true,
+          required: false,
+          type: 'number',
           handleChange: value => {
             handleInputChange('level', value);
           },
@@ -245,11 +258,12 @@ const SearchParams: FC<SearchParamsProps> = ({
         search_k: {
           label: 'search_k',
           key: 'search_k',
-          value: searchParamsForm['search_k'] || '',
+          value: searchParamsForm['search_k'] ?? topK,
           min: topK,
           // n * n_trees can be infinity
           max: Infinity,
           isInt: true,
+          type: 'number',
           handleChange: value => {
             handleInputChange('search_k', value);
           },
@@ -257,10 +271,11 @@ const SearchParams: FC<SearchParamsProps> = ({
         search_length: {
           label: 'search_length',
           key: 'search_length',
-          value: searchParamsForm['search_length'] || '',
+          value: searchParamsForm['search_length'] ?? '',
           min: 10,
           max: 300,
           isInt: true,
+          type: 'number',
           handleChange: value => {
             handleInputChange('search_length', value);
           },
@@ -268,10 +283,11 @@ const SearchParams: FC<SearchParamsProps> = ({
         search_list: {
           label: 'search_list',
           key: 'search_list',
-          value: searchParamsForm['search_list'] || '',
+          value: searchParamsForm['search_list'] ?? '',
           min: 150,
           max: 65535,
           isInt: true,
+          type: 'number',
           handleChange: value => {
             handleInputChange('search_list', value);
           },
@@ -279,10 +295,11 @@ const SearchParams: FC<SearchParamsProps> = ({
         drop_ratio_search: {
           label: 'drop_ratio_search',
           key: 'drop_ratio_search',
-          value: searchParamsForm['drop_ratio_search'] || '',
+          value: searchParamsForm['drop_ratio_search'] ?? '',
           min: 0,
           max: 1,
           isInt: false,
+          type: 'number',
           handleChange: value => {
             handleInputChange('drop_ratio_search', value);
           },
@@ -290,14 +307,14 @@ const SearchParams: FC<SearchParamsProps> = ({
       };
 
       const param = configParamMap[paramKey];
-      return getNumberInputConfig(param);
+      return getInputConfig(param);
     },
     [
       indexParams,
       searchParamsForm,
       classes.inlineInput,
       topK,
-      getNumberInputConfig,
+      getInputConfig,
       handleInputChange,
     ]
   );
@@ -312,7 +329,7 @@ const SearchParams: FC<SearchParamsProps> = ({
       {}
     );
     handleFormChange(form);
-  }, [searchParams, handleFormChange]);
+  }, []);
 
   const checkedForm = useMemo(() => {
     const { ...needCheckItems } = searchParamsForm;
@@ -327,18 +344,6 @@ const SearchParams: FC<SearchParamsProps> = ({
 
   return (
     <div className={wrapperClass}>
-      {/* consistency level */}
-      <CustomSelector
-        options={CONSISTENCY_LEVEL_OPTIONS}
-        value={consistency_level || ConsistencyLevelEnum.Bounded}
-        label={collectionTrans('consistencyLevel')}
-        wrapperClass={classes.selector}
-        variant="filled"
-        onChange={(e: { target: { value: unknown } }) => {
-          const consistency = e.target.value as string;
-          handleConsistencyChange(consistency);
-        }}
-      />
       <div className={classes.inlineInputWrapper}>
         {/* dynamic params, now every type only has one param except metric type */}
         {searchParams.map(param => (
@@ -356,16 +361,3 @@ const SearchParams: FC<SearchParamsProps> = ({
 };
 
 export default SearchParams;
-
-// <CustomSelector
-// options={metricOptions}
-// value={metricType}
-// label={indexTrans('metric')}
-// wrapperClass={classes.selector}
-// variant="filled"
-// disabled={true}
-// onChange={(e: { target: { value: unknown } }) => {
-//   const metricType = e.target.value as string;
-//   handleMetricTypeChange(metricType);
-// }}
-// />

+ 1 - 1
client/src/pages/search/Styles.ts

@@ -20,7 +20,7 @@ export const getVectorSearchStyles = makeStyles((theme: Theme) => ({
       marginTop: theme.spacing(0),
       marginBottom: theme.spacing(1),
       overflow: 'scroll',
-      height: '130px',
+      height: '65px',
       maxWidth: '100%',
       width: '100%',
       display: 'block',

+ 7 - 6
client/src/pages/search/Types.ts

@@ -5,14 +5,14 @@ import { FieldObject, KeyValuePair } from '@server/types';
 
 export interface SearchParamsProps {
   // default index type is FLAT
-  indexType: string;
+  indexType?: string;
   // index extra params, e.g. nlist
-  indexParams: KeyValuePair[];
+  indexParams?: KeyValuePair[];
   searchParamsForm: {
-    [key in string]: number;
+    [key in string]: number | string;
   };
+  handleFormChange: (form: { [key in string]: number | string }) => void;
   topK: number;
-  handleFormChange: (form: { [key in string]: number }) => void;
   handleConsistencyChange: (type: string) => void;
   wrapperClass?: string;
   setParamsDisabled: (isDisabled: boolean) => void;
@@ -33,9 +33,10 @@ export interface FieldOption extends Option {
 export interface SearchParamInputConfig {
   label: string;
   key: searchKeywordsType;
-  min: number;
-  max: number;
+  min?: number;
+  max?: number;
   isInt?: boolean;
+  type?: 'number' | 'text';
   // no value: empty string
   value: number | string;
   handleChange: (value: number) => void;

+ 3 - 3
client/src/pages/search/VectorSearch.tsx

@@ -57,9 +57,9 @@ const VectorSearch = () => {
   const [selectedField, setSelectedField] = useState<string>('');
 
   // search params form
-  const [searchParam, setSearchParam] = useState<{ [key in string]: number }>(
-    {}
-  );
+  const [searchParam, setSearchParam] = useState<
+    { [key in string]: number | string }
+  >({});
   // search params disable state
   const [paramDisabled, setParamDisabled] = useState<boolean>(true);
   // use null as init value before search, empty array means no results

+ 1 - 12
client/src/types/SearchTypes.ts

@@ -44,18 +44,7 @@ export interface SearchParamInputConfig {
   className?: string;
 }
 
-export interface VectorSearchParam {
-  expr?: string;
-  search_params: {
-    anns_field: string; // your vector field name
-    topk: string | number;
-    metric_type: string;
-    params: string;
-  };
-  vectors: any;
-  output_fields: string[];
-  vector_type: DataTypeEnum;
-}
+export type VectorSearchParam  = any
 
 export interface SearchResult {
   // dynamic field names

+ 88 - 0
client/src/utils/Format.ts

@@ -3,12 +3,14 @@ import {
   DEFAULT_MILVUS_PORT,
   DEFAULT_PROMETHEUS_PORT,
   VectorTypes,
+  DataTypeStringEnum,
 } from '@/consts';
 import {
   CreateFieldType,
   CreateField,
 } from '@/pages/databases/collections/Types';
 import { FieldObject } from '@server/types';
+import { generateVector } from './';
 
 /**
  * transform large capacity to capacity in b.
@@ -217,3 +219,89 @@ export const formatFieldType = (field: FieldObject) => {
 
   return `${data_type}${elementType}${maxCap}${dim}${maxLn}`;
 };
+
+export const isSparseVector = (str: string): boolean => {
+  try {
+    str = str.trim();
+
+    if (str === '') return false;
+
+    if (str[0] !== '{' || str[str.length - 1] !== '}') return false;
+
+    const innerStr = str.slice(1, -1);
+
+    const pairs = innerStr.split(',');
+
+    for (const pair of pairs) {
+      const [key, value] = pair.split(':');
+      const trimmedKey = key && key.trim();
+      const trimmedValue = value && value.trim();
+      if (
+        !(
+          (trimmedKey.match(/^".*"$/) && trimmedKey.length > 2) ||
+          trimmedKey.match(/^\d+$/)
+        ) ||
+        !trimmedValue.match(/^(\d*\.)?\d+$/)
+      ) {
+        return false;
+      }
+    }
+
+    return true;
+  } catch (error) {
+    return false;
+  }
+};
+
+// transform ObjStr To JSONStr
+// `{a: 1, b: 2}` => `{"a": 1, "b": 2}`
+// `{'a': 1, 'b': 2}` => `{"a": 1, "b": 2}`
+// `{'a': 1, b: 2}` => `{"a": 1, "b": 2}`
+// it may have empty space between key and value
+export const transformObjStrToJSONStr = (str: string): string => {
+  const objStr = str.replace(/'/g, '"').replace(/(\w+)\s*:/g, '"$1":');
+  return objStr;
+};
+
+// transform object to valid string without quotes
+// {a: 1, b: 2} => '{a: 1, b: 2}'
+export const transformObjToStr = (obj: any): string => {
+  const str = JSON.stringify(obj);
+  return str.replace(/"/g, '');
+};
+
+export const generateVectorsByField = (field: FieldObject) => {
+  switch (field.data_type) {
+    case DataTypeStringEnum.FloatVector:
+    case DataTypeStringEnum.BinaryVector:
+    case DataTypeStringEnum.Float16Vector:
+    case DataTypeStringEnum.BFloat16Vector:
+      const dim =
+        field.data_type === DataTypeStringEnum.BinaryVector
+          ? field.dimension / 8
+          : field.dimension;
+      return JSON.stringify(generateVector(dim));
+    case 'SparseFloatVector':
+      return transformObjToStr({
+        [Math.floor(Math.random() * 10)]: Math.random(),
+      });
+    default:
+      return [1, 2, 3];
+  }
+};
+
+const arrayFormatter = (value: string) => {
+  return JSON.parse(value);
+};
+
+const sparseVectorFormatter = (str: string) => {
+  return JSON.parse(transformObjStrToJSONStr(str));
+};
+
+export const VectorStrToObject = {
+  [DataTypeStringEnum.FloatVector]: arrayFormatter,
+  [DataTypeStringEnum.BinaryVector]: arrayFormatter,
+  [DataTypeStringEnum.Float16Vector]: arrayFormatter,
+  [DataTypeStringEnum.BFloat16Vector]: arrayFormatter,
+  [DataTypeStringEnum.SparseFloatVector]: sparseVectorFormatter,
+};

+ 12 - 0
client/src/utils/Insert.ts

@@ -34,6 +34,18 @@ export const parseValue = (value: string) => {
   }
 };
 
+export const formatValue = (value: any) => {
+  if (Array.isArray(value) && value.length > 0) {
+    return `[${value}]`;
+  }
+
+  if (typeof value === 'object' && value && !Array.isArray(value)) {
+    return JSON.stringify(value);
+  }
+
+  return value;
+};
+
 /**
  *
  * @param heads table heads, e.g. ['field1', 'field2', 'field3']

+ 133 - 0
client/yarn.lock

@@ -417,6 +417,83 @@
   resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
   integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
 
+"@codemirror/autocomplete@^6.0.0":
+  version "6.16.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.16.0.tgz#595eb30099ba91a835ed65ed8ff7497388f604b3"
+  integrity sha512-P/LeCTtZHRTCU4xQsa89vSKWecYv1ZqwzOd5topheGRf+qtacFgBeIMQi3eL8Kt/BUNvxUWkx+5qP2jlGoARrg==
+  dependencies:
+    "@codemirror/language" "^6.0.0"
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.17.0"
+    "@lezer/common" "^1.0.0"
+
+"@codemirror/commands@^6.0.0":
+  version "6.5.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.5.0.tgz#e7dfb7918e7af8889d5731ff4c46ffafd7687353"
+  integrity sha512-rK+sj4fCAN/QfcY9BEzYMgp4wwL/q5aj/VfNSoH1RWPF9XS/dUwBkvlL3hpWgEjOqlpdN1uLC9UkjJ4tmyjJYg==
+  dependencies:
+    "@codemirror/language" "^6.0.0"
+    "@codemirror/state" "^6.4.0"
+    "@codemirror/view" "^6.0.0"
+    "@lezer/common" "^1.1.0"
+
+"@codemirror/lang-javascript@^6.2.2":
+  version "6.2.2"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz#7141090b22994bef85bcc5608a3bc1257f2db2ad"
+  integrity sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==
+  dependencies:
+    "@codemirror/autocomplete" "^6.0.0"
+    "@codemirror/language" "^6.6.0"
+    "@codemirror/lint" "^6.0.0"
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.17.0"
+    "@lezer/common" "^1.0.0"
+    "@lezer/javascript" "^1.0.0"
+
+"@codemirror/language@^6.0.0", "@codemirror/language@^6.6.0":
+  version "6.10.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.10.1.tgz#428c932a158cb75942387acfe513c1ece1090b05"
+  integrity sha512-5GrXzrhq6k+gL5fjkAwt90nYDmjlzTIJV8THnxNFtNKWotMIlzzN+CpqxqwXOECnUdOndmSeWntVrVcv5axWRQ==
+  dependencies:
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.23.0"
+    "@lezer/common" "^1.1.0"
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.0.0"
+    style-mod "^4.0.0"
+
+"@codemirror/lint@^6.0.0":
+  version "6.7.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.7.0.tgz#b252169512f826d5d742f1d82b478d53548c9ba6"
+  integrity sha512-LTLOL2nT41ADNSCCCCw8Q/UmdAFzB23OUYSjsHTdsVaH0XEo+orhuqbDNWzrzodm14w6FOxqxpmy4LF8Lixqjw==
+  dependencies:
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.0.0"
+    crelt "^1.0.5"
+
+"@codemirror/search@^6.0.0":
+  version "6.5.6"
+  resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.5.6.tgz#8f858b9e678d675869112e475f082d1e8488db93"
+  integrity sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==
+  dependencies:
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.0.0"
+    crelt "^1.0.5"
+
+"@codemirror/state@^6.0.0", "@codemirror/state@^6.4.0", "@codemirror/state@^6.4.1":
+  version "6.4.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.4.1.tgz#da57143695c056d9a3c38705ed34136e2b68171b"
+  integrity sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==
+
+"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0":
+  version "6.26.3"
+  resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.26.3.tgz#47aebd49a6ee3c8d36b82046d3bffe6056b8039f"
+  integrity sha512-gmqxkPALZjkgSxIeeweY/wGQXBfwTUaLs8h7OKtSwfbj9Ct3L11lD+u1sS7XHppxFQoMDiMDp07P9f3I2jWOHw==
+  dependencies:
+    "@codemirror/state" "^6.4.0"
+    style-mod "^4.1.0"
+    w3c-keyname "^2.2.4"
+
 "@date-io/core@1.x", "@date-io/core@^1.3.13":
   version "1.3.13"
   resolved "https://registry.yarnpkg.com/@date-io/core/-/core-1.3.13.tgz#90c71da493f20204b7a972929cc5c482d078b3fa"
@@ -648,6 +725,34 @@
     "@streamparser/json" "^0.0.17"
     lodash.get "^4.4.2"
 
+"@lezer/common@^1.0.0", "@lezer/common@^1.1.0", "@lezer/common@^1.2.0":
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.2.1.tgz#198b278b7869668e1bebbe687586e12a42731049"
+  integrity sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==
+
+"@lezer/highlight@^1.0.0", "@lezer/highlight@^1.1.3":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.2.0.tgz#e5898c3644208b4b589084089dceeea2966f7780"
+  integrity sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==
+  dependencies:
+    "@lezer/common" "^1.0.0"
+
+"@lezer/javascript@^1.0.0":
+  version "1.4.16"
+  resolved "https://registry.yarnpkg.com/@lezer/javascript/-/javascript-1.4.16.tgz#20f0ba832b7bf4f1333513549f2f7ca459a9dc93"
+  integrity sha512-84UXR3N7s11MPQHWgMnjb9571fr19MmXnr5zTv2XX0gHXXUvW3uPJ8GCjKrfTXmSdfktjRK0ayKklw+A13rk4g==
+  dependencies:
+    "@lezer/common" "^1.2.0"
+    "@lezer/highlight" "^1.1.3"
+    "@lezer/lr" "^1.3.0"
+
+"@lezer/lr@^1.0.0", "@lezer/lr@^1.3.0":
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.4.0.tgz#ed52a75dbbfbb0d1eb63710ea84c35ee647cb67e"
+  integrity sha512-Wst46p51km8gH0ZUmeNrtpRYmdlRHUpN1DQd3GFAyKANi8WVz8c2jHYTf1CVScFaCjQw1iO3ZZdqGDxQPRErTg==
+  dependencies:
+    "@lezer/common" "^1.0.0"
+
 "@material-ui/core@4.12.4":
   version "4.12.4"
   resolved "https://registry.yarnpkg.com/@material-ui/core/-/core-4.12.4.tgz#4ac17488e8fcaf55eb6a7f5efb2a131e10138a73"
@@ -1793,6 +1898,19 @@ clsx@^1.0.2, clsx@^1.0.4, clsx@^1.1.1:
   resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188"
   integrity sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==
 
+codemirror@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-6.0.1.tgz#62b91142d45904547ee3e0e0e4c1a79158035a29"
+  integrity sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==
+  dependencies:
+    "@codemirror/autocomplete" "^6.0.0"
+    "@codemirror/commands" "^6.0.0"
+    "@codemirror/language" "^6.0.0"
+    "@codemirror/lint" "^6.0.0"
+    "@codemirror/search" "^6.0.0"
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.0.0"
+
 color-convert@^1.9.0:
   version "1.9.3"
   resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
@@ -1859,6 +1977,11 @@ cosmiconfig@^8.1.3:
     parse-json "^5.2.0"
     path-type "^4.0.0"
 
+crelt@^1.0.5:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72"
+  integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==
+
 cross-spawn@^7.0.0, cross-spawn@^7.0.3:
   version "7.0.3"
   resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
@@ -4160,6 +4283,11 @@ strip-literal@^1.3.0:
   dependencies:
     acorn "^8.10.0"
 
+style-mod@^4.0.0, style-mod@^4.1.0:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.1.2.tgz#ca238a1ad4786520f7515a8539d5a63691d7bf67"
+  integrity sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==
+
 supports-color@^5.3.0:
   version "5.5.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
@@ -4414,6 +4542,11 @@ void-elements@3.1.0:
   resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09"
   integrity sha1-YU9/v42AHwu18GYfWy9XhXUOTwk=
 
+w3c-keyname@^2.2.4:
+  version "2.2.8"
+  resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5"
+  integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==
+
 w3c-xmlserializer@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-3.0.0.tgz#06cdc3eefb7e4d0b20a560a5a3aeb0d2d9a65923"

+ 8 - 0
package.json

@@ -18,6 +18,14 @@
   },
   "private": true,
   "dependencies": {
+    "@codemirror/commands": "^6.5.0",
+    "@codemirror/lang-javascript": "^6.2.2",
+    "@codemirror/lang-json": "^6.0.1",
+    "@codemirror/language": "^6.10.1",
+    "@codemirror/lint": "^6.7.0",
+    "@codemirror/state": "^6.4.1",
+    "@codemirror/view": "^6.26.3",
+    "codemirror": "^6.0.1",
     "react-router-dom": "^6.20.0"
   },
   "devDependencies": {

+ 1 - 5
server/src/collections/collections.controller.ts

@@ -94,11 +94,7 @@ export class CollectionController {
     );
     // we need use req.body, so we can't use delete here
     this.router.put('/:name/entities', this.deleteEntities.bind(this));
-    this.router.post(
-      '/:name/search',
-      dtoValidationMiddleware(VectorSearchDto),
-      this.vectorSearch.bind(this)
-    );
+    this.router.post('/:name/search', this.vectorSearch.bind(this));
     // query
     this.router.post(
       '/:name/query',

+ 1 - 0
server/src/types/index.ts

@@ -3,6 +3,7 @@ export {
   ShowCollectionsType,
   MilvusClient,
   ResStatus,
+  RerankerObj,
 } from '@zilliz/milvus2-sdk-node';
 
 export * from './collections.type';

+ 150 - 0
yarn.lock

@@ -2,6 +2,128 @@
 # yarn lockfile v1
 
 
+"@codemirror/autocomplete@^6.0.0":
+  version "6.16.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.16.0.tgz#595eb30099ba91a835ed65ed8ff7497388f604b3"
+  integrity sha512-P/LeCTtZHRTCU4xQsa89vSKWecYv1ZqwzOd5topheGRf+qtacFgBeIMQi3eL8Kt/BUNvxUWkx+5qP2jlGoARrg==
+  dependencies:
+    "@codemirror/language" "^6.0.0"
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.17.0"
+    "@lezer/common" "^1.0.0"
+
+"@codemirror/commands@^6.0.0", "@codemirror/commands@^6.5.0":
+  version "6.5.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.5.0.tgz#e7dfb7918e7af8889d5731ff4c46ffafd7687353"
+  integrity sha512-rK+sj4fCAN/QfcY9BEzYMgp4wwL/q5aj/VfNSoH1RWPF9XS/dUwBkvlL3hpWgEjOqlpdN1uLC9UkjJ4tmyjJYg==
+  dependencies:
+    "@codemirror/language" "^6.0.0"
+    "@codemirror/state" "^6.4.0"
+    "@codemirror/view" "^6.0.0"
+    "@lezer/common" "^1.1.0"
+
+"@codemirror/lang-javascript@^6.2.2":
+  version "6.2.2"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz#7141090b22994bef85bcc5608a3bc1257f2db2ad"
+  integrity sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==
+  dependencies:
+    "@codemirror/autocomplete" "^6.0.0"
+    "@codemirror/language" "^6.6.0"
+    "@codemirror/lint" "^6.0.0"
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.17.0"
+    "@lezer/common" "^1.0.0"
+    "@lezer/javascript" "^1.0.0"
+
+"@codemirror/lang-json@^6.0.1":
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-json/-/lang-json-6.0.1.tgz#0a0be701a5619c4b0f8991f9b5e95fe33f462330"
+  integrity sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==
+  dependencies:
+    "@codemirror/language" "^6.0.0"
+    "@lezer/json" "^1.0.0"
+
+"@codemirror/language@^6.0.0", "@codemirror/language@^6.10.1", "@codemirror/language@^6.6.0":
+  version "6.10.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.10.1.tgz#428c932a158cb75942387acfe513c1ece1090b05"
+  integrity sha512-5GrXzrhq6k+gL5fjkAwt90nYDmjlzTIJV8THnxNFtNKWotMIlzzN+CpqxqwXOECnUdOndmSeWntVrVcv5axWRQ==
+  dependencies:
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.23.0"
+    "@lezer/common" "^1.1.0"
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.0.0"
+    style-mod "^4.0.0"
+
+"@codemirror/lint@^6.0.0", "@codemirror/lint@^6.7.0":
+  version "6.7.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.7.0.tgz#b252169512f826d5d742f1d82b478d53548c9ba6"
+  integrity sha512-LTLOL2nT41ADNSCCCCw8Q/UmdAFzB23OUYSjsHTdsVaH0XEo+orhuqbDNWzrzodm14w6FOxqxpmy4LF8Lixqjw==
+  dependencies:
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.0.0"
+    crelt "^1.0.5"
+
+"@codemirror/search@^6.0.0":
+  version "6.5.6"
+  resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.5.6.tgz#8f858b9e678d675869112e475f082d1e8488db93"
+  integrity sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==
+  dependencies:
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.0.0"
+    crelt "^1.0.5"
+
+"@codemirror/state@^6.0.0", "@codemirror/state@^6.4.0", "@codemirror/state@^6.4.1":
+  version "6.4.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.4.1.tgz#da57143695c056d9a3c38705ed34136e2b68171b"
+  integrity sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==
+
+"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.26.3":
+  version "6.26.3"
+  resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.26.3.tgz#47aebd49a6ee3c8d36b82046d3bffe6056b8039f"
+  integrity sha512-gmqxkPALZjkgSxIeeweY/wGQXBfwTUaLs8h7OKtSwfbj9Ct3L11lD+u1sS7XHppxFQoMDiMDp07P9f3I2jWOHw==
+  dependencies:
+    "@codemirror/state" "^6.4.0"
+    style-mod "^4.1.0"
+    w3c-keyname "^2.2.4"
+
+"@lezer/common@^1.0.0", "@lezer/common@^1.1.0", "@lezer/common@^1.2.0":
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.2.1.tgz#198b278b7869668e1bebbe687586e12a42731049"
+  integrity sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==
+
+"@lezer/highlight@^1.0.0", "@lezer/highlight@^1.1.3":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.2.0.tgz#e5898c3644208b4b589084089dceeea2966f7780"
+  integrity sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==
+  dependencies:
+    "@lezer/common" "^1.0.0"
+
+"@lezer/javascript@^1.0.0":
+  version "1.4.16"
+  resolved "https://registry.yarnpkg.com/@lezer/javascript/-/javascript-1.4.16.tgz#20f0ba832b7bf4f1333513549f2f7ca459a9dc93"
+  integrity sha512-84UXR3N7s11MPQHWgMnjb9571fr19MmXnr5zTv2XX0gHXXUvW3uPJ8GCjKrfTXmSdfktjRK0ayKklw+A13rk4g==
+  dependencies:
+    "@lezer/common" "^1.2.0"
+    "@lezer/highlight" "^1.1.3"
+    "@lezer/lr" "^1.3.0"
+
+"@lezer/json@^1.0.0":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@lezer/json/-/json-1.0.2.tgz#bdc849e174113e2d9a569a5e6fb1a27e2f703eaf"
+  integrity sha512-xHT2P4S5eeCYECyKNPhr4cbEL9tc8w83SPwRC373o9uEdrvGKTZoJVAGxpOsZckMlEh9W23Pc72ew918RWQOBQ==
+  dependencies:
+    "@lezer/common" "^1.2.0"
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.0.0"
+
+"@lezer/lr@^1.0.0", "@lezer/lr@^1.3.0":
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.4.0.tgz#ed52a75dbbfbb0d1eb63710ea84c35ee647cb67e"
+  integrity sha512-Wst46p51km8gH0ZUmeNrtpRYmdlRHUpN1DQd3GFAyKANi8WVz8c2jHYTf1CVScFaCjQw1iO3ZZdqGDxQPRErTg==
+  dependencies:
+    "@lezer/common" "^1.0.0"
+
 "@remix-run/router@1.13.0":
   version "1.13.0"
   resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.13.0.tgz#7e29c4ee85176d9c08cb0f4456bff74d092c5065"
@@ -14,6 +136,24 @@
   dependencies:
     lru-cache "*"
 
+codemirror@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-6.0.1.tgz#62b91142d45904547ee3e0e0e4c1a79158035a29"
+  integrity sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==
+  dependencies:
+    "@codemirror/autocomplete" "^6.0.0"
+    "@codemirror/commands" "^6.0.0"
+    "@codemirror/language" "^6.0.0"
+    "@codemirror/lint" "^6.0.0"
+    "@codemirror/search" "^6.0.0"
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.0.0"
+
+crelt@^1.0.5:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72"
+  integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==
+
 lru-cache@*:
   version "10.1.0"
   resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.1.0.tgz#2098d41c2dc56500e6c88584aa656c84de7d0484"
@@ -33,3 +173,13 @@ react-router@6.20.0:
   integrity sha512-pVvzsSsgUxxtuNfTHC4IxjATs10UaAtvLGVSA1tbUE4GDaOSU1Esu2xF5nWLz7KPiMuW8BJWuPFdlGYJ7/rW0w==
   dependencies:
     "@remix-run/router" "1.13.0"
+
+style-mod@^4.0.0, style-mod@^4.1.0:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.1.2.tgz#ca238a1ad4786520f7515a8539d5a63691d7bf67"
+  integrity sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==
+
+w3c-keyname@^2.2.4:
+  version "2.2.8"
+  resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5"
+  integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==