diff --git a/aperag/api/components/schemas/document.yaml b/aperag/api/components/schemas/document.yaml index 361a4b587..78a75ff34 100644 --- a/aperag/api/components/schemas/document.yaml +++ b/aperag/api/components/schemas/document.yaml @@ -82,3 +82,19 @@ documentUpdate: type: string source: type: string + +rebuildIndexesRequest: + type: object + properties: + index_types: + type: array + items: + type: string + enum: + - vector + - fulltext + - graph + description: Types of indexes to rebuild + minItems: 1 + required: + - index_types diff --git a/aperag/api/openapi.yaml b/aperag/api/openapi.yaml index 4b0a1120e..8047a2610 100644 --- a/aperag/api/openapi.yaml +++ b/aperag/api/openapi.yaml @@ -47,6 +47,8 @@ paths: $ref: './paths/collections.yaml#/documents' /collections/{collection_id}/documents/{document_id}: $ref: './paths/collections.yaml#/document' + /collections/{collection_id}/documents/{document_id}/rebuild_indexes: + $ref: './paths/collections.yaml#/rebuild_indexes' /collections/{collection_id}/searches: $ref: './paths/collections.yaml#/searches' /collections/{collection_id}/searches/{search_id}: diff --git a/aperag/api/paths/collections.yaml b/aperag/api/paths/collections.yaml index 2a0ca9bd8..58e738af0 100644 --- a/aperag/api/paths/collections.yaml +++ b/aperag/api/paths/collections.yaml @@ -296,6 +296,45 @@ document: schema: $ref: '../components/schemas/common.yaml#/failResponse' +rebuild_indexes: + post: + summary: Rebuild document indexes + description: Rebuild specified types of indexes for a document + security: + - BearerAuth: [] + parameters: + - name: collection_id + in: path + required: true + schema: + type: string + - name: document_id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '../components/schemas/document.yaml#/rebuildIndexesRequest' + responses: + '204': + description: Index rebuild initiated successfully + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '../components/schemas/common.yaml#/failResponse' + '404': + description: Document not found + content: + application/json: + schema: + $ref: '../components/schemas/common.yaml#/failResponse' + searches: get: summary: Get search history diff --git a/aperag/index/manager.py b/aperag/index/manager.py index 3b8a0219c..7bcf8cbdd 100644 --- a/aperag/index/manager.py +++ b/aperag/index/manager.py @@ -107,6 +107,40 @@ async def delete_document_indexes( if doc_index: doc_index.update_spec(IndexDesiredState.ABSENT) + async def rebuild_document_indexes( + self, session: AsyncSession, document_id: str, index_types: List[DocumentIndexType] + ): + """ + Rebuild specified document indexes (called when user requests index rebuild) + + This increments the version of specified indexes to trigger reconciliation. + + Args: + session: Database session + document_id: Document ID + index_types: List of index types to rebuild + """ + if len(set(index_types)) != len(index_types): + raise Exception("Duplicate index types are not allowed") + + for index_type in index_types: + stmt = select(DocumentIndex).where( + and_(DocumentIndex.document_id == document_id, DocumentIndex.index_type == index_type) + ) + result = await session.execute(stmt) + doc_index = result.scalar_one_or_none() + + if doc_index: + # Only rebuild if the index is present or failed + if doc_index.desired_state == IndexDesiredState.PRESENT: + doc_index.version += 1 # Increment version to trigger re-indexing + doc_index.gmt_updated = utc_now() + logger.info(f"Triggered rebuild for {index_type.value} index of document {document_id}") + else: + logger.warning(f"Cannot rebuild {index_type.value} index for document {document_id}: index not present") + else: + logger.warning(f"No {index_type.value} index found for document {document_id}") + async def get_document_index_status(self, session: AsyncSession, document_id: str) -> dict: """ Get current index status for a document diff --git a/aperag/schema/view_models.py b/aperag/schema/view_models.py index 993469ab0..d91941ae3 100644 --- a/aperag/schema/view_models.py +++ b/aperag/schema/view_models.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: openapi.merged.yaml -# timestamp: 2025-06-23T03:26:47+00:00 +# timestamp: 2025-06-23T09:37:51+00:00 from __future__ import annotations @@ -575,6 +575,12 @@ class DocumentUpdate(BaseModel): source: Optional[str] = None +class RebuildIndexesRequest(BaseModel): + index_types: list[Literal['vector', 'fulltext', 'graph']] = Field( + ..., description='Types of indexes to rebuild', min_items=1 + ) + + class VectorSearchParams(BaseModel): topk: Optional[int] = Field(None, description='Top K results') similarity: Optional[confloat(ge=0.0, le=1.0)] = Field( diff --git a/aperag/service/document_service.py b/aperag/service/document_service.py index 380ba926e..96e2c4619 100644 --- a/aperag/service/document_service.py +++ b/aperag/service/document_service.py @@ -402,6 +402,71 @@ async def _delete_documents_atomically(session): return result + async def rebuild_document_indexes( + self, user_id: str, collection_id: str, document_id: str, index_types: List[str] + ) -> dict: + """ + Rebuild specified indexes for a document + + Args: + user_id: User ID + collection_id: Collection ID + document_id: Document ID + index_types: List of index types to rebuild ('vector', 'fulltext', 'graph') + + Returns: + dict: Success response + """ + if len(set(index_types)) != len(index_types): + raise invalid_param("index_types", "duplicate index types are not allowed") + + logger.info(f"Rebuilding indexes for document {document_id} with types: {index_types}") + + # Convert index types to enum values outside transaction + from aperag.db.models import DocumentIndexType + index_type_enums = [] + for index_type in index_types: + if index_type == 'vector': + index_type_enums.append(DocumentIndexType.VECTOR) + elif index_type == 'fulltext': + index_type_enums.append(DocumentIndexType.FULLTEXT) + elif index_type == 'graph': + index_type_enums.append(DocumentIndexType.GRAPH) + else: + raise invalid_param("index_type", f"Invalid index type: {index_type}") + + # Execute all operations atomically in a single transaction + async def _rebuild_document_indexes_atomically(session): + # Verify document exists and user has access + document = await self.db_ops.query_document(user_id, collection_id, document_id) + if not document: + raise DocumentNotFoundException(f"Document {document_id} not found") + + if document.collection_id != collection_id: + raise ResourceNotFoundException(f"Document {document_id} not found in collection {collection_id}") + + # Verify user has access to the collection + collection = await self.db_ops.query_collection(user_id, collection_id) + if not collection or collection.user != user_id: + raise ResourceNotFoundException(f"Collection {collection_id} not found or access denied") + + # Trigger index rebuild by incrementing version for selected index types + await document_index_manager.rebuild_document_indexes(session, document_id, index_type_enums) + + logger.info(f"Successfully triggered rebuild for document {document_id} indexes: {index_types}") + + return { + "code": "200", + "message": f"Index rebuild initiated for types: {', '.join(index_types)}" + } + + result = await self.db_ops.execute_with_transaction(_rebuild_document_indexes_atomically) + + # Trigger index reconciliation after successful rebuild initiation + _trigger_index_reconciliation() + + return result + # Create a global service instance for easy access # This uses the global db_ops instance and doesn't require session management in views diff --git a/aperag/views/main.py b/aperag/views/main.py index cd26fab37..ebb13ea63 100644 --- a/aperag/views/main.py +++ b/aperag/views/main.py @@ -158,6 +158,21 @@ async def delete_documents_view( return await document_service.delete_documents(str(user.id), collection_id, document_ids) +@router.post("/collections/{collection_id}/documents/{document_id}/rebuild_indexes") +@audit(resource_type="document", api_name="RebuildDocumentIndexes") +async def rebuild_document_indexes_view( + request: Request, + collection_id: str, + document_id: str, + rebuild_request: view_models.RebuildIndexesRequest, + user: User = Depends(current_user), +): + """Rebuild specified indexes for a document""" + return await document_service.rebuild_document_indexes( + str(user.id), collection_id, document_id, rebuild_request.index_types + ) + + @router.post("/bots/{bot_id}/chats") @audit(resource_type="chat", api_name="CreateChat") async def create_chat_view(request: Request, bot_id: str, user: User = Depends(current_user)) -> view_models.Chat: diff --git a/frontend/src/api/apis/default-api.ts b/frontend/src/api/apis/default-api.ts index 922845c9a..d569bc7eb 100644 --- a/frontend/src/api/apis/default-api.ts +++ b/frontend/src/api/apis/default-api.ts @@ -102,6 +102,8 @@ import type { ModelConfigList } from '../models'; // @ts-ignore import type { PromptTemplateList } from '../models'; // @ts-ignore +import type { RebuildIndexesRequest } from '../models'; +// @ts-ignore import type { Register } from '../models'; // @ts-ignore import type { SearchRequest } from '../models'; @@ -1109,6 +1111,54 @@ export const DefaultApiAxiosParamCreator = function (configuration?: Configurati options: localVarRequestOptions, }; }, + /** + * Rebuild specified types of indexes for a document + * @summary Rebuild document indexes + * @param {string} collectionId + * @param {string} documentId + * @param {RebuildIndexesRequest} rebuildIndexesRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + collectionsCollectionIdDocumentsDocumentIdRebuildIndexesPost: async (collectionId: string, documentId: string, rebuildIndexesRequest: RebuildIndexesRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'collectionId' is not null or undefined + assertParamExists('collectionsCollectionIdDocumentsDocumentIdRebuildIndexesPost', 'collectionId', collectionId) + // verify required parameter 'documentId' is not null or undefined + assertParamExists('collectionsCollectionIdDocumentsDocumentIdRebuildIndexesPost', 'documentId', documentId) + // verify required parameter 'rebuildIndexesRequest' is not null or undefined + assertParamExists('collectionsCollectionIdDocumentsDocumentIdRebuildIndexesPost', 'rebuildIndexesRequest', rebuildIndexesRequest) + const localVarPath = `/collections/{collection_id}/documents/{document_id}/rebuild_indexes` + .replace(`{${"collection_id"}}`, encodeURIComponent(String(collectionId))) + .replace(`{${"document_id"}}`, encodeURIComponent(String(documentId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(rebuildIndexesRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Get a list of documents * @summary List documents @@ -2530,6 +2580,21 @@ export const DefaultApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['DefaultApi.collectionsCollectionIdDocumentsDocumentIdPut']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Rebuild specified types of indexes for a document + * @summary Rebuild document indexes + * @param {string} collectionId + * @param {string} documentId + * @param {RebuildIndexesRequest} rebuildIndexesRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async collectionsCollectionIdDocumentsDocumentIdRebuildIndexesPost(collectionId: string, documentId: string, rebuildIndexesRequest: RebuildIndexesRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.collectionsCollectionIdDocumentsDocumentIdRebuildIndexesPost(collectionId, documentId, rebuildIndexesRequest, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['DefaultApi.collectionsCollectionIdDocumentsDocumentIdRebuildIndexesPost']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Get a list of documents * @summary List documents @@ -3163,6 +3228,16 @@ export const DefaultApiFactory = function (configuration?: Configuration, basePa collectionsCollectionIdDocumentsDocumentIdPut(requestParameters: DefaultApiCollectionsCollectionIdDocumentsDocumentIdPutRequest, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.collectionsCollectionIdDocumentsDocumentIdPut(requestParameters.collectionId, requestParameters.documentId, requestParameters.documentUpdate, options).then((request) => request(axios, basePath)); }, + /** + * Rebuild specified types of indexes for a document + * @summary Rebuild document indexes + * @param {DefaultApiCollectionsCollectionIdDocumentsDocumentIdRebuildIndexesPostRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + collectionsCollectionIdDocumentsDocumentIdRebuildIndexesPost(requestParameters: DefaultApiCollectionsCollectionIdDocumentsDocumentIdRebuildIndexesPostRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.collectionsCollectionIdDocumentsDocumentIdRebuildIndexesPost(requestParameters.collectionId, requestParameters.documentId, requestParameters.rebuildIndexesRequest, options).then((request) => request(axios, basePath)); + }, /** * Get a list of documents * @summary List documents @@ -3694,6 +3769,16 @@ export interface DefaultApiInterface { */ collectionsCollectionIdDocumentsDocumentIdPut(requestParameters: DefaultApiCollectionsCollectionIdDocumentsDocumentIdPutRequest, options?: RawAxiosRequestConfig): AxiosPromise; + /** + * Rebuild specified types of indexes for a document + * @summary Rebuild document indexes + * @param {DefaultApiCollectionsCollectionIdDocumentsDocumentIdRebuildIndexesPostRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApiInterface + */ + collectionsCollectionIdDocumentsDocumentIdRebuildIndexesPost(requestParameters: DefaultApiCollectionsCollectionIdDocumentsDocumentIdRebuildIndexesPostRequest, options?: RawAxiosRequestConfig): AxiosPromise; + /** * Get a list of documents * @summary List documents @@ -4434,6 +4519,34 @@ export interface DefaultApiCollectionsCollectionIdDocumentsDocumentIdPutRequest readonly documentUpdate: DocumentUpdate } +/** + * Request parameters for collectionsCollectionIdDocumentsDocumentIdRebuildIndexesPost operation in DefaultApi. + * @export + * @interface DefaultApiCollectionsCollectionIdDocumentsDocumentIdRebuildIndexesPostRequest + */ +export interface DefaultApiCollectionsCollectionIdDocumentsDocumentIdRebuildIndexesPostRequest { + /** + * + * @type {string} + * @memberof DefaultApiCollectionsCollectionIdDocumentsDocumentIdRebuildIndexesPost + */ + readonly collectionId: string + + /** + * + * @type {string} + * @memberof DefaultApiCollectionsCollectionIdDocumentsDocumentIdRebuildIndexesPost + */ + readonly documentId: string + + /** + * + * @type {RebuildIndexesRequest} + * @memberof DefaultApiCollectionsCollectionIdDocumentsDocumentIdRebuildIndexesPost + */ + readonly rebuildIndexesRequest: RebuildIndexesRequest +} + /** * Request parameters for collectionsCollectionIdDocumentsGet operation in DefaultApi. * @export @@ -5134,6 +5247,18 @@ export class DefaultApi extends BaseAPI implements DefaultApiInterface { return DefaultApiFp(this.configuration).collectionsCollectionIdDocumentsDocumentIdPut(requestParameters.collectionId, requestParameters.documentId, requestParameters.documentUpdate, options).then((request) => request(this.axios, this.basePath)); } + /** + * Rebuild specified types of indexes for a document + * @summary Rebuild document indexes + * @param {DefaultApiCollectionsCollectionIdDocumentsDocumentIdRebuildIndexesPostRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApi + */ + public collectionsCollectionIdDocumentsDocumentIdRebuildIndexesPost(requestParameters: DefaultApiCollectionsCollectionIdDocumentsDocumentIdRebuildIndexesPostRequest, options?: RawAxiosRequestConfig) { + return DefaultApiFp(this.configuration).collectionsCollectionIdDocumentsDocumentIdRebuildIndexesPost(requestParameters.collectionId, requestParameters.documentId, requestParameters.rebuildIndexesRequest, options).then((request) => request(this.axios, this.basePath)); + } + /** * Get a list of documents * @summary List documents diff --git a/frontend/src/api/models/index.ts b/frontend/src/api/models/index.ts index 08172cf23..ea46d69a5 100644 --- a/frontend/src/api/models/index.ts +++ b/frontend/src/api/models/index.ts @@ -76,6 +76,7 @@ export * from './node-position'; export * from './page-result'; export * from './prompt-template'; export * from './prompt-template-list'; +export * from './rebuild-indexes-request'; export * from './reference'; export * from './register'; export * from './rerank-document'; diff --git a/frontend/src/api/models/rebuild-indexes-request.ts b/frontend/src/api/models/rebuild-indexes-request.ts new file mode 100644 index 000000000..811139211 --- /dev/null +++ b/frontend/src/api/models/rebuild-indexes-request.ts @@ -0,0 +1,39 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * ApeRAG API + * ApeRAG API Documentation + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * + * @export + * @interface RebuildIndexesRequest + */ +export interface RebuildIndexesRequest { + /** + * Types of indexes to rebuild + * @type {Array} + * @memberof RebuildIndexesRequest + */ + 'index_types': Array; +} + +export const RebuildIndexesRequestIndexTypesEnum = { + vector: 'vector', + fulltext: 'fulltext', + graph: 'graph' +} as const; + +export type RebuildIndexesRequestIndexTypesEnum = typeof RebuildIndexesRequestIndexTypesEnum[keyof typeof RebuildIndexesRequestIndexTypesEnum]; + + diff --git a/frontend/src/api/openapi.merged.yaml b/frontend/src/api/openapi.merged.yaml index e2e49be48..a2176db17 100644 --- a/frontend/src/api/openapi.merged.yaml +++ b/frontend/src/api/openapi.merged.yaml @@ -686,6 +686,44 @@ paths: application/json: schema: $ref: '#/components/schemas/failResponse' + /collections/{collection_id}/documents/{document_id}/rebuild_indexes: + post: + summary: Rebuild document indexes + description: Rebuild specified types of indexes for a document + security: + - BearerAuth: [] + parameters: + - name: collection_id + in: path + required: true + schema: + type: string + - name: document_id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/rebuildIndexesRequest' + responses: + '204': + description: Index rebuild initiated successfully + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/failResponse' + '404': + description: Document not found + content: + application/json: + schema: + $ref: '#/components/schemas/failResponse' /collections/{collection_id}/searches: get: summary: Get search history @@ -2795,6 +2833,20 @@ components: type: string source: type: string + rebuildIndexesRequest: + type: object + properties: + index_types: + type: array + items: + type: string + enum: + - vector + - fulltext + - graph + description: Types of indexes to rebuild + required: + - index_types vectorSearchParams: type: object properties: diff --git a/frontend/src/locales/en-US.ts b/frontend/src/locales/en-US.ts index bb7eb6031..3eb0089db 100644 --- a/frontend/src/locales/en-US.ts +++ b/frontend/src/locales/en-US.ts @@ -230,6 +230,13 @@ export default { 'document.index.type.vector': 'Vector Index', 'document.index.type.fulltext': 'Fulltext Index', 'document.index.type.graph': 'Graph Index', + 'document.index.rebuild': 'Rebuild Index', + 'document.index.rebuild.title': 'Rebuild Index', + 'document.index.rebuild.description': 'Select index types to rebuild', + 'document.index.rebuild.select.all': 'Select All', + 'document.index.rebuild.confirm': 'Confirm Rebuild', + 'document.index.rebuild.success': 'Index rebuild task initiated', + 'document.index.rebuild.failed': 'Index rebuild failed', flow: '---------------', 'flow.name': 'Workflow', diff --git a/frontend/src/locales/zh-CN.ts b/frontend/src/locales/zh-CN.ts index 78f3898bf..fbe8b8667 100644 --- a/frontend/src/locales/zh-CN.ts +++ b/frontend/src/locales/zh-CN.ts @@ -228,6 +228,13 @@ export default { 'document.index.type.vector': '向量索引', 'document.index.type.fulltext': '全文索引', 'document.index.type.graph': '图索引', + 'document.index.rebuild': '重建索引', + 'document.index.rebuild.title': '重建索引', + 'document.index.rebuild.description': '选择要重建的索引类型', + 'document.index.rebuild.select.all': '全选', + 'document.index.rebuild.confirm': '确认重建', + 'document.index.rebuild.success': '索引重建任务已启动', + 'document.index.rebuild.failed': '索引重建失败', flow: '---------------', 'flow.name': '任务流', diff --git a/frontend/src/pages/collections/$collectionId/documents.tsx b/frontend/src/pages/collections/$collectionId/documents.tsx index fb943b315..8db28f53e 100644 --- a/frontend/src/pages/collections/$collectionId/documents.tsx +++ b/frontend/src/pages/collections/$collectionId/documents.tsx @@ -20,12 +20,14 @@ import { DeleteOutlined, MoreOutlined, SearchOutlined, + ReloadOutlined, } from '@ant-design/icons'; import { useRequest } from 'ahooks'; import { Avatar, Badge, Button, + Checkbox, Dropdown, Input, Modal, @@ -56,6 +58,9 @@ export default () => { const { token } = theme.useToken(); const [modal, contextHolder] = Modal.useModal(); const { formatMessage } = useIntl(); + const [rebuildModalVisible, setRebuildModalVisible] = useState(false); + const [rebuildSelectedDocument, setRebuildSelectedDocument] = useState(null); + const [rebuildSelectedTypes, setRebuildSelectedTypes] = useState([]); const { data: documentsRes, run: getDocuments, @@ -86,6 +91,48 @@ export default () => { [collectionId], ); + const rebuildIndexes = useCallback( + async (documentId: string, indexTypes: string[]) => { + if (!collectionId || !documentId || indexTypes.length === 0) return; + + try { + await api.collectionsCollectionIdDocumentsDocumentIdRebuildIndexesPost({ + collectionId, + documentId, + rebuildIndexesRequest: { + index_types: indexTypes as any, + }, + }); + toast.success(formatMessage({ id: 'document.index.rebuild.success' })); + getDocuments(); + } catch (error) { + toast.error(formatMessage({ id: 'document.index.rebuild.failed' })); + } + }, + [collectionId, formatMessage], + ); + + const handleRebuildIndex = useCallback((record: ApeDocument) => { + setRebuildSelectedDocument(record); + setRebuildSelectedTypes([]); + setRebuildModalVisible(true); + }, []); + + const handleRebuildConfirm = useCallback(() => { + if (rebuildSelectedDocument && rebuildSelectedTypes.length > 0) { + rebuildIndexes(rebuildSelectedDocument.id!, rebuildSelectedTypes); + setRebuildModalVisible(false); + setRebuildSelectedDocument(null); + setRebuildSelectedTypes([]); + } + }, [rebuildSelectedDocument, rebuildSelectedTypes, rebuildIndexes]); + + const indexTypeOptions = [ + { label: formatMessage({ id: 'document.index.type.vector' }), value: 'vector' }, + { label: formatMessage({ id: 'document.index.type.fulltext' }), value: 'fulltext' }, + { label: formatMessage({ id: 'document.index.type.graph' }), value: 'graph' }, + ]; + const renderIndexStatus = ( vectorStatus?: DocumentVectorIndexStatusEnum, fulltextStatus?: DocumentFulltextIndexStatusEnum, @@ -206,6 +253,13 @@ export default () => { trigger={['click']} menu={{ items: [ + { + key: 'rebuild', + label: formatMessage({ id: 'document.index.rebuild' }), + icon: , + disabled: record.status === 'DELETING' || record.status === 'DELETED', + onClick: () => handleRebuildIndex(record), + }, { key: 'delete', label: formatMessage({ id: 'action.delete' }), @@ -273,15 +327,15 @@ export default () => { const documents = useMemo( () => - documentsRes?.data.items - ?.map((document) => { + documentsRes?.data?.items + ?.map((document: any) => { const item: ApeDocument = { ...document, config: parseConfig(document.config), }; return item; }) - .filter((item) => { + .filter((item: ApeDocument) => { const titleMatch = searchParams?.name ? item.name?.includes(searchParams.name) : true; @@ -330,6 +384,59 @@ export default () => { {contextHolder} + + { + setRebuildModalVisible(false); + setRebuildSelectedDocument(null); + setRebuildSelectedTypes([]); + }} + onOk={handleRebuildConfirm} + okText={formatMessage({ id: 'document.index.rebuild.confirm' })} + cancelText={formatMessage({ id: 'action.cancel' })} + okButtonProps={{ + disabled: rebuildSelectedTypes.length === 0, + }} + > +
+ + {formatMessage({ id: 'document.index.rebuild.description' })} + +
+ + {rebuildSelectedDocument && ( +
+ + {rebuildSelectedDocument.name} + +
+ )} + +
+ 0 && rebuildSelectedTypes.length < indexTypeOptions.length} + checked={rebuildSelectedTypes.length === indexTypeOptions.length} + onChange={(e) => { + if (e.target.checked) { + setRebuildSelectedTypes(indexTypeOptions.map(option => option.value)); + } else { + setRebuildSelectedTypes([]); + } + }} + > + {formatMessage({ id: 'document.index.rebuild.select.all' })} + +
+ + setRebuildSelectedTypes(values as string[])} + style={{ display: 'flex', flexDirection: 'row', gap: 16 }} + /> +
); }; diff --git a/tests/e2e_test/test_document.py b/tests/e2e_test/test_document.py index 8c3886468..b1bde27d0 100644 --- a/tests/e2e_test/test_document.py +++ b/tests/e2e_test/test_document.py @@ -129,3 +129,114 @@ def test_upload_duplicate_then_delete_and_reupload(client, collection): assert "items" in data3 assert len(data3["items"]) == 1 assert data3["items"][0]["name"] == filename + + +# Document index rebuild tests +def test_rebuild_single_index_type(client, document, collection): + """Test rebuilding a single index type (vector)""" + doc_id = document["id"] + collection_id = collection["id"] + + # Test rebuilding vector index + rebuild_request = {"index_types": ["vector"]} + response = client.post( + f"/api/v1/collections/{collection_id}/documents/{doc_id}/rebuild_indexes", + json=rebuild_request + ) + assert response.status_code == HTTPStatus.OK, response.text + data = response.json() + assert data["code"] == "200" + assert "vector" in data["message"] + + +def test_rebuild_all_index_types(client, document, collection): + """Test rebuilding all supported index types""" + doc_id = document["id"] + collection_id = collection["id"] + + # Test rebuilding all index types + rebuild_request = {"index_types": ["vector", "fulltext", "graph"]} + response = client.post( + f"/api/v1/collections/{collection_id}/documents/{doc_id}/rebuild_indexes", + json=rebuild_request + ) + assert response.status_code == HTTPStatus.OK, response.text + data = response.json() + assert data["code"] == "200" + assert "vector" in data["message"] + assert "fulltext" in data["message"] + assert "graph" in data["message"] + + +def test_rebuild_index_invalid_index_type(client, document, collection): + """Test rebuilding with invalid index type""" + doc_id = document["id"] + collection_id = collection["id"] + + # Test with invalid index type + rebuild_request = {"index_types": ["invalid_type"]} + response = client.post( + f"/api/v1/collections/{collection_id}/documents/{doc_id}/rebuild_indexes", + json=rebuild_request + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, response.text + + +def test_rebuild_index_empty_index_types(client, document, collection): + """Test rebuilding with empty index types array""" + doc_id = document["id"] + collection_id = collection["id"] + + # Test with empty index types + rebuild_request = {"index_types": []} + response = client.post( + f"/api/v1/collections/{collection_id}/documents/{doc_id}/rebuild_indexes", + json=rebuild_request + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, response.text + + +@pytest.mark.parametrize("index_type", ["vector", "fulltext", "graph"]) +def test_rebuild_individual_index_types(client, document, collection, index_type): + """Test rebuilding each individual index type""" + doc_id = document["id"] + collection_id = collection["id"] + + rebuild_request = {"index_types": [index_type]} + response = client.post( + f"/api/v1/collections/{collection_id}/documents/{doc_id}/rebuild_indexes", + json=rebuild_request + ) + assert response.status_code == HTTPStatus.OK, response.text + data = response.json() + assert data["code"] == "200" + assert index_type in data["message"] + + +def test_rebuild_index_duplicate_types(client, document, collection): + """Test rebuilding with duplicate index types in request""" + doc_id = document["id"] + collection_id = collection["id"] + + # Test with duplicate index types + rebuild_request = {"index_types": ["vector", "vector", "fulltext"]} + response = client.post( + f"/api/v1/collections/{collection_id}/documents/{doc_id}/rebuild_indexes", + json=rebuild_request + ) + # Should fail + assert response.status_code == HTTPStatus.BAD_REQUEST, response.text + + +def test_rebuild_index_case_sensitivity(client, document, collection): + """Test rebuilding with different case index types""" + doc_id = document["id"] + collection_id = collection["id"] + + # Test with uppercase index type (should fail) + rebuild_request = {"index_types": ["VECTOR"]} + response = client.post( + f"/api/v1/collections/{collection_id}/documents/{doc_id}/rebuild_indexes", + json=rebuild_request + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, response.text