diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentchunk/DocumentChunkSyncTaskAppService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentchunk/DocumentChunkSyncTaskAppService.java index d97d2f12..e324a726 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentchunk/DocumentChunkSyncTaskAppService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentchunk/DocumentChunkSyncTaskAppService.java @@ -5,6 +5,7 @@ import com.easyagents.core.model.embedding.EmbeddingOptions; import com.easyagents.core.store.DocumentStore; import com.easyagents.core.store.StoreOptions; import com.easyagents.core.store.StoreResult; +import com.easyagents.core.store.VectorData; import com.easyagents.search.engine.service.DocumentSearcher; import com.easyagents.search.engine.service.KeywordSearchMetadataKeys; import com.easyagents.store.milvus.MilvusVectorStore; @@ -22,6 +23,7 @@ import tech.easyflow.ai.entity.DocumentChunk; import tech.easyflow.ai.entity.DocumentChunkSyncTask; import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.Model; +import tech.easyflow.ai.dto.DocumentChunkSyncStatus; import tech.easyflow.ai.mapper.DocumentChunkMapper; import tech.easyflow.ai.mapper.DocumentChunkSyncTaskMapper; import tech.easyflow.ai.service.DocumentCollectionService; @@ -33,7 +35,12 @@ import java.math.BigInteger; import java.time.Duration; import java.util.Collections; import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; /** * 持久化分块索引同步任务的投递、执行和恢复。 @@ -116,6 +123,31 @@ public class DocumentChunkSyncTaskAppService { } } + public List listSyncStatuses(List chunks) { + if (chunks.isEmpty()) { + return List.of(); + } + Map tasks = taskMapper.selectCurrentForChunks( + chunks.stream().map(DocumentChunk::getId).toList() + ).stream().collect(Collectors.toMap(DocumentChunkSyncTask::getChunkId, Function.identity())); + return chunks.stream().map(chunk -> { + DocumentChunkSyncTask task = tasks.get(chunk.getId()); + boolean current = task != null + && Objects.equals(chunk.getIndexSyncVersion(), task.getSyncVersion()); + // 正文状态可能在两次查询间推进,只有同版本仍在重试的任务提供失败原因。 + boolean retrying = current && DocumentChunkSyncState.PENDING.equals(chunk.getIndexSyncStatus()) + && (DocumentChunkSyncState.PENDING.equals(task.getStatus()) + || DocumentChunkSyncState.TASK_RUNNING.equals(task.getStatus())); + return new DocumentChunkSyncStatus( + chunk.getId(), chunk.getIndexSyncStatus(), chunk.getIndexSyncVersion(), + retrying ? task.getErrorCode() : chunk.getIndexSyncErrorCode(), + retrying ? task.getErrorMessage() : chunk.getIndexSyncErrorMessage(), + current ? task.getAttemptCount() : null, + MAX_ATTEMPTS + ); + }).toList(); + } + public void dispatchPendingTasks() { Date now = new Date(); taskMapper.recoverExpired(now); @@ -228,6 +260,7 @@ public class DocumentChunkSyncTaskAppService { StoreContext context = prepareUpsertContext(collection, task.getVectorCollection()); try { com.easyagents.core.document.Document document = toSearchDocument(chunk, task.getDocumentCollectionId()); + embedDocument(context, document); StoreResult vectorResult = context.documentStore.update( Collections.singletonList(document), context.storeOptions @@ -248,6 +281,21 @@ public class DocumentChunkSyncTaskAppService { } } + private void embedDocument(StoreContext context, com.easyagents.core.document.Document document) { + try { + VectorData vectorData = context.documentStore.getEmbeddingModel().embed( + document, context.storeOptions.getEmbeddingOptions() + ); + if (vectorData == null || vectorData.getVector() == null || vectorData.getVector().length == 0) { + throw new IllegalStateException("向量模型未返回有效向量"); + } + // update 复用已生成的向量,不重复调用模型;分开捕获以区分模型与索引写入失败。 + document.setVector(vectorData.getVector()); + } catch (RuntimeException exception) { + throw new IndexSyncException("EMBEDDING_REQUEST_FAILED", "向量模型服务调用失败", exception); + } + } + private void synchronizeDelete(DocumentChunkSyncTask task) { MilvusVectorStoreConfig storeConfig = milvusConfig.copyForCollection(task.getVectorCollection()); AiMilvusClientManager clientManager = milvusClientManagerProvider.getObject(); diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/dto/DocumentChunkSyncStatus.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/dto/DocumentChunkSyncStatus.java index d1f01347..2e6568d2 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/dto/DocumentChunkSyncStatus.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/dto/DocumentChunkSyncStatus.java @@ -10,6 +10,8 @@ public record DocumentChunkSyncStatus( String indexSyncStatus, Long indexSyncVersion, String indexSyncErrorCode, - String indexSyncErrorMessage + String indexSyncErrorMessage, + Integer indexSyncAttemptCount, + int indexSyncMaxAttempts ) { } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mapper/DocumentChunkSyncTaskMapper.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mapper/DocumentChunkSyncTaskMapper.java index 82d1ec03..1d5b1f79 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mapper/DocumentChunkSyncTaskMapper.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mapper/DocumentChunkSyncTaskMapper.java @@ -44,6 +44,13 @@ public interface DocumentChunkSyncTaskMapper extends BaseMapper lockChunkTasks(@Param("chunkId") BigInteger chunkId); + @Select("") + List selectCurrentForChunks(@Param("ids") List ids); + @Select("SELECT " + SELECT_COLUMNS + " FROM tb_document_chunk_sync_task " + "WHERE status='PENDING' AND next_retry_at <= #{now} " + "AND (last_dispatched_at IS NULL OR last_dispatched_at <= #{redispatchBefore}) " @@ -67,7 +74,7 @@ public interface DocumentChunkSyncTaskMapper extends BaseMapper new DocumentChunkSyncStatus( - chunk.getId(), - chunk.getIndexSyncStatus(), - chunk.getIndexSyncVersion(), - chunk.getIndexSyncErrorCode(), - chunk.getIndexSyncErrorMessage() - )).toList(); + return syncTaskAppService.listSyncStatuses(chunks); } private DocumentChunk requireChunk(BigInteger knowledgeId, BigInteger chunkId) { diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/documentchunk/DocumentChunkSyncTaskAppServiceTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/documentchunk/DocumentChunkSyncTaskAppServiceTest.java index 72f7489b..8e8113be 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/documentchunk/DocumentChunkSyncTaskAppServiceTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/documentchunk/DocumentChunkSyncTaskAppServiceTest.java @@ -1,6 +1,14 @@ package tech.easyflow.ai.documentchunk; import org.junit.Test; +import org.junit.Assert; +import com.easyagents.core.document.Document; +import com.easyagents.core.model.embedding.EmbeddingModel; +import com.easyagents.core.model.exception.ModelException; +import com.easyagents.core.store.DocumentStore; +import com.easyagents.core.store.StoreResult; +import com.easyagents.core.store.VectorData; +import com.easyagents.search.engine.service.DocumentSearcher; import org.mockito.InOrder; import org.mockito.Mockito; import org.springframework.transaction.PlatformTransactionManager; @@ -10,6 +18,8 @@ import tech.easyflow.ai.config.AiMilvusConfig; import tech.easyflow.ai.config.SearcherFactory; import tech.easyflow.ai.entity.DocumentChunk; import tech.easyflow.ai.entity.DocumentChunkSyncTask; +import tech.easyflow.ai.entity.DocumentCollection; +import tech.easyflow.ai.entity.Model; import tech.easyflow.ai.mapper.DocumentChunkMapper; import tech.easyflow.ai.mapper.DocumentChunkSyncTaskMapper; import tech.easyflow.ai.service.DocumentCollectionService; @@ -25,6 +35,144 @@ import java.util.function.Supplier; */ public class DocumentChunkSyncTaskAppServiceTest { + @Test + public void pollingShouldExposeCurrentRetryReasonWhileWaitingAndRunning() { + Fixture fixture = fixture(1); + fixture.task.setErrorCode("EMBEDDING_REQUEST_FAILED"); + fixture.task.setErrorMessage("向量模型服务调用失败"); + Mockito.when(fixture.taskMapper.selectCurrentForChunks(List.of(fixture.chunkId))) + .thenReturn(List.of(fixture.task)); + + var pending = fixture.service.listSyncStatuses(List.of(fixture.chunk)).get(0); + Assert.assertEquals("PENDING", pending.indexSyncStatus()); + Assert.assertEquals("EMBEDDING_REQUEST_FAILED", pending.indexSyncErrorCode()); + Assert.assertEquals(Integer.valueOf(1), pending.indexSyncAttemptCount()); + Assert.assertEquals(5, pending.indexSyncMaxAttempts()); + + fixture.task.setStatus(DocumentChunkSyncState.TASK_RUNNING); + fixture.task.setAttemptCount(2); + var running = fixture.service.listSyncStatuses(List.of(fixture.chunk)).get(0); + Assert.assertEquals("EMBEDDING_REQUEST_FAILED", running.indexSyncErrorCode()); + Assert.assertEquals(Integer.valueOf(2), running.indexSyncAttemptCount()); + } + + @Test + public void pollingShouldNotExposeAnotherVersionsOrFinishedTasksFailure() { + Fixture fixture = fixture(2); + fixture.task.setErrorCode("EMBEDDING_REQUEST_FAILED"); + Mockito.when(fixture.taskMapper.selectCurrentForChunks(List.of(fixture.chunkId))) + .thenReturn(List.of(fixture.task)); + fixture.task.setSyncVersion(2L); + var changed = fixture.service.listSyncStatuses(List.of(fixture.chunk)).get(0); + Assert.assertNull(changed.indexSyncErrorCode()); + Assert.assertNull(changed.indexSyncAttemptCount()); + + fixture.task.setSyncVersion(1L); + fixture.task.setStatus(DocumentChunkSyncState.TASK_SUCCEEDED); + var finished = fixture.service.listSyncStatuses(List.of(fixture.chunk)).get(0); + Assert.assertNull(finished.indexSyncErrorCode()); + } + + @Test + public void pollingShouldHandleChunksWithoutTasksAndSkipEmptyBatch() { + Fixture fixture = fixture(1); + Assert.assertTrue(fixture.service.listSyncStatuses(List.of()).isEmpty()); + Mockito.verify(fixture.taskMapper, Mockito.never()).selectCurrentForChunks(Mockito.anyList()); + Mockito.when(fixture.taskMapper.selectCurrentForChunks(List.of(fixture.chunkId))) + .thenReturn(List.of()); + var state = fixture.service.listSyncStatuses(List.of(fixture.chunk)).get(0); + Assert.assertEquals("PENDING", state.indexSyncStatus()); + Assert.assertNull(state.indexSyncAttemptCount()); + Assert.assertNull(state.indexSyncErrorCode()); + } + + @Test + public void embeddingFailureShouldBeReportedWithoutCallingIndexes() { + Fixture fixture = fixture(1); + IndexFixture indexes = prepareIndexes(fixture); + Mockito.when(indexes.embeddingModel.embed(Mockito.any(Document.class), Mockito.any())) + .thenThrow(new ModelException("response is null or empty.")); + + fixture.service.handleTask(fixture.taskId); + + Mockito.verify(fixture.taskMapper).failOrRetryOwned( + Mockito.eq(fixture.taskId), Mockito.anyString(), Mockito.eq("PENDING"), Mockito.any(), + Mockito.eq("EMBEDDING_REQUEST_FAILED"), Mockito.eq("向量模型服务调用失败"), Mockito.any() + ); + Mockito.verify(indexes.store, Mockito.never()).doUpdate(Mockito.anyList(), Mockito.any()); + Mockito.verifyNoInteractions(indexes.searcher); + } + + @Test + public void vectorFailureShouldHaveItsOwnReasonAndEmbedOnlyOnce() { + Fixture fixture = fixture(1); + IndexFixture indexes = prepareIndexes(fixture); + Mockito.when(indexes.store.doUpdate(Mockito.anyList(), Mockito.any())) + .thenReturn(StoreResult.fail("vector write failed")); + + fixture.service.handleTask(fixture.taskId); + + Mockito.verify(fixture.taskMapper).failOrRetryOwned( + Mockito.eq(fixture.taskId), Mockito.anyString(), Mockito.eq("PENDING"), Mockito.any(), + Mockito.eq("VECTOR_UPSERT_FAILED"), Mockito.anyString(), Mockito.any() + ); + Mockito.verify(indexes.embeddingModel).embed(Mockito.any(Document.class), Mockito.any()); + Mockito.verifyNoInteractions(indexes.searcher); + } + + @Test + public void keywordFailureShouldHaveItsOwnReason() { + Fixture fixture = fixture(1); + IndexFixture indexes = prepareIndexes(fixture); + Mockito.when(indexes.searcher.addDocuments(Mockito.anyList())).thenReturn(false); + + fixture.service.handleTask(fixture.taskId); + + Mockito.verify(fixture.taskMapper).failOrRetryOwned( + Mockito.eq(fixture.taskId), Mockito.anyString(), Mockito.eq("PENDING"), Mockito.any(), + Mockito.eq("KEYWORD_UPSERT_FAILED"), Mockito.anyString(), Mockito.any() + ); + } + + @Test + public void successfulIndexUpdateShouldClearFailureAndMarkChunkSynced() { + Fixture fixture = fixture(2); + fixture.task.setErrorCode("EMBEDDING_REQUEST_FAILED"); + IndexFixture indexes = prepareIndexes(fixture); + Mockito.when(indexes.searcher.addDocuments(Mockito.anyList())).thenReturn(true); + + fixture.service.handleTask(fixture.taskId); + + Mockito.verify(fixture.taskMapper).finishOwned( + Mockito.eq(fixture.taskId), Mockito.anyString(), Mockito.eq("SUCCEEDED"), + Mockito.isNull(), Mockito.isNull(), Mockito.any() + ); + Mockito.verify(fixture.chunkMapper).updateSyncState(fixture.chunkId, 1L, "SYNCED", null, null); + Mockito.verify(indexes.embeddingModel).embed(Mockito.any(Document.class), Mockito.any()); + } + + private static IndexFixture prepareIndexes(Fixture fixture) { + DocumentCollection collection = Mockito.mock(DocumentCollection.class); + DocumentStore store = Mockito.mock(DocumentStore.class, Mockito.CALLS_REAL_METHODS); + Model model = Mockito.mock(Model.class); + EmbeddingModel embeddingModel = Mockito.mock(EmbeddingModel.class); + DocumentSearcher searcher = Mockito.mock(DocumentSearcher.class); + Mockito.when(fixture.collectionService.getById(fixture.task.getDocumentCollectionId())) + .thenReturn(collection); + Mockito.when(collection.toDocumentStore()).thenReturn(store); + Mockito.when(fixture.modelService.getModelInstance(Mockito.any())).thenReturn(model); + Mockito.when(model.toEmbeddingModel()).thenReturn(embeddingModel); + Mockito.when(fixture.searcherFactory.getSearcher()).thenReturn(searcher); + VectorData vector = new VectorData(); + vector.setVector(new float[] { 0.1f, 0.2f }); + Mockito.when(embeddingModel.embed(Mockito.any(Document.class), Mockito.any())).thenReturn(vector); + Mockito.when(store.doUpdate(Mockito.anyList(), Mockito.any())).thenReturn(StoreResult.success()); + return new IndexFixture(store, embeddingModel, searcher); + } + + private record IndexFixture(DocumentStore store, EmbeddingModel embeddingModel, DocumentSearcher searcher) { + } + @Test public void dispatchPendingShouldRecoverExpiredTasksAndSendClaimedRows() { Fixture fixture = fixture(1); @@ -215,7 +363,7 @@ public class DocumentChunkSyncTaskAppServiceTest { Mockito.mock(ObjectProvider.class) ); return new Fixture(service, taskMapper, chunkMapper, collectionService, - producer, task, chunk, taskId, chunkId); + producer, task, chunk, taskId, chunkId, modelService, searcherFactory); } private record Fixture( @@ -227,7 +375,9 @@ public class DocumentChunkSyncTaskAppServiceTest { DocumentChunkSyncTask task, DocumentChunk chunk, BigInteger taskId, - BigInteger chunkId + BigInteger chunkId, + ModelService modelService, + SearcherFactory searcherFactory ) { } } diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/DocumentChunkServiceImplTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/DocumentChunkServiceImplTest.java index 83d78c55..0ce6186f 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/DocumentChunkServiceImplTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/DocumentChunkServiceImplTest.java @@ -154,6 +154,11 @@ public class DocumentChunkServiceImplTest { Mockito.when(fixture.chunkMapper.selectSyncStates( fixture.documentId, List.of(fixture.chunkId) )).thenReturn(List.of(state)); + Mockito.when(fixture.syncTaskAppService.listSyncStatuses(List.of(state))) + .thenReturn(List.of(new tech.easyflow.ai.dto.DocumentChunkSyncStatus( + state.getId(), state.getIndexSyncStatus(), state.getIndexSyncVersion(), + null, null, 0, 5 + ))); List result = fixture.service.listIndexSyncStatus( diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json b/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json index 9bc349e3..a16ba8f1 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json @@ -282,6 +282,10 @@ "discardChanges": "Discard changes", "chunkSourceFallback": "Switched to source editing", "chunkSyncPending": "Updating search index", + "chunkSyncEmbeddingRetry": "Retrying embedding service ({attempt}/{maxAttempts})", + "chunkSyncVectorRetry": "Retrying vector index ({attempt}/{maxAttempts})", + "chunkSyncKeywordRetry": "Retrying keyword index ({attempt}/{maxAttempts})", + "chunkSyncIndexRetry": "Retrying search index ({attempt}/{maxAttempts})", "chunkSyncSucceeded": "Search index updated", "chunkSyncFailed": "Index sync failed. Click to retry", "chunkSyncRetryFailed": "Failed to retry index sync", diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json index 7d9b2d97..e71e2579 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json @@ -282,6 +282,10 @@ "discardChanges": "放弃修改", "chunkSourceFallback": "已切换到源码编辑", "chunkSyncPending": "正在更新检索索引", + "chunkSyncEmbeddingRetry": "向量模型服务重试中({attempt}/{maxAttempts})", + "chunkSyncVectorRetry": "向量索引重试中({attempt}/{maxAttempts})", + "chunkSyncKeywordRetry": "关键词索引重试中({attempt}/{maxAttempts})", + "chunkSyncIndexRetry": "检索索引重试中({attempt}/{maxAttempts})", "chunkSyncSucceeded": "检索索引已更新", "chunkSyncFailed": "索引同步失败,点击重试", "chunkSyncRetryFailed": "索引同步重试失败", diff --git a/easyflow-ui-admin/app/src/views/ai/documentCollection/ChunkDocumentTable.test.ts b/easyflow-ui-admin/app/src/views/ai/documentCollection/ChunkDocumentTable.test.ts index f2d5310e..ed550e85 100644 --- a/easyflow-ui-admin/app/src/views/ai/documentCollection/ChunkDocumentTable.test.ts +++ b/easyflow-ui-admin/app/src/views/ai/documentCollection/ChunkDocumentTable.test.ts @@ -44,7 +44,7 @@ vi.mock('element-plus', async (importOriginal) => { vi.mock('#/api/request', () => ({ api: {} })); vi.mock('@easyflow/locales', () => ({ - $t: (key: string) => { + $t: (key: string, params: Record = {}) => { const messages: Record = { 'documentCollection.continueEditing': '继续编辑', 'documentCollection.deleteChunk': '删除分块', @@ -54,8 +54,18 @@ vi.mock('@easyflow/locales', () => ({ 'documentCollection.chunkSyncSucceeded': '检索索引已更新', 'documentCollection.chunkSyncFailed': '索引同步失败,点击重试', 'documentCollection.chunkSyncRetryFailed': '索引同步重试失败', + 'documentCollection.chunkSyncEmbeddingRetry': + '向量模型服务重试中({attempt}/{maxAttempts})', + 'documentCollection.chunkSyncVectorRetry': + '向量索引重试中({attempt}/{maxAttempts})', + 'documentCollection.chunkSyncKeywordRetry': + '关键词索引重试中({attempt}/{maxAttempts})', + 'documentCollection.chunkSyncIndexRetry': + '检索索引重试中({attempt}/{maxAttempts})', }; - return messages[key] || key; + return (messages[key] || key).replaceAll(/\{(\w+)\}/g, (_, name: string) => + String(params[name]), + ); }, })); @@ -652,6 +662,113 @@ describe('chunkDocumentTable', () => { expect(vi.getTimerCount()).toBe(0); }); + it.each([ + ['EMBEDDING_REQUEST_FAILED', '向量模型服务重试中'], + ['VECTOR_UPSERT_FAILED', '向量索引重试中'], + ['KEYWORD_UPSERT_FAILED', '关键词索引重试中'], + ['INDEX_UPSERT_FAILED', '检索索引重试中'], + ])('轮询展示真实次数并区分重试原因 %s', async (code, label) => { + vi.useFakeTimers(); + const pendingRow = { ...row, indexSyncStatus: 'PENDING' }; + const post = vi.fn().mockResolvedValue({ + errorCode: 0, + data: [ + { + id: row.id, + indexSyncStatus: 'PENDING', + indexSyncVersion: row.indexSyncVersion, + indexSyncErrorCode: code, + indexSyncAttemptCount: 1, + indexSyncMaxAttempts: 5, + }, + ], + }); + const { wrapper } = mountTable(post, [pendingRow]); + await flushPromises(); + expect(wrapper.find('.chunk-sync-retry-hint').exists()).toBe(false); + + await vi.advanceTimersByTimeAsync(2000); + await flushPromises(); + expect(wrapper.get('.chunk-sync-retry-hint').text()).toBe( + `${label}(1/5)`, + ); + expect(wrapper.get('.chunk-sync-retry-hint').attributes('role')).toBe( + 'status', + ); + + post.mockResolvedValue({ + errorCode: 0, + data: [ + { + id: row.id, + indexSyncStatus: 'SYNCED', + indexSyncVersion: row.indexSyncVersion, + }, + ], + }); + await vi.advanceTimersByTimeAsync(2000); + await flushPromises(); + expect(wrapper.find('.chunk-sync-retry-hint').exists()).toBe(false); + expect( + (wrapper.findComponent(PageData).vm as any).getPageRows()[0] + .indexSyncErrorCode, + ).toBeNull(); + }); + + it.each(['xlsx', 'pdf'])( + '两种分块布局均展示重试提示:%s', + async (sourceFileExt) => { + const { wrapper } = mountTable(vi.fn(), [ + { + ...row, + options: { + ...row.options, + sourceFileExt, + sheetName: sourceFileExt === 'xlsx' ? 'Sheet1' : undefined, + }, + indexSyncStatus: 'PENDING', + indexSyncErrorCode: 'EMBEDDING_REQUEST_FAILED', + indexSyncAttemptCount: 2, + indexSyncMaxAttempts: 5, + }, + ]); + await flushPromises(); + expect(wrapper.get('.chunk-sync-retry-hint').text()).toBe( + '向量模型服务重试中(2/5)', + ); + }, + ); + + it('新版本保存后清除上个版本的重试提示', async () => { + const { wrapper } = mountTable( + vi.fn().mockResolvedValue({ + errorCode: 0, + data: { + content: '新正文', + indexSyncStatus: 'PENDING', + indexSyncVersion: 2, + }, + }), + [ + { + ...row, + indexSyncStatus: 'PENDING', + indexSyncVersion: 1, + indexSyncErrorCode: 'EMBEDDING_REQUEST_FAILED', + indexSyncAttemptCount: 2, + indexSyncMaxAttempts: 5, + }, + ], + ); + await flushPromises(); + await getButton(wrapper, 'button.edit').trigger('click'); + await flushPromises(); + await wrapper.get('.editor-stub').setValue('新正文'); + await getButton(wrapper, 'button.save').trigger('click'); + await flushPromises(); + expect(wrapper.find('.chunk-sync-retry-hint').exists()).toBe(false); + }); + it('实时编辑器保真失败时使用同一草稿切换源码模式', async () => { const { wrapper } = mountTable(); await flushPromises(); diff --git a/easyflow-ui-admin/app/src/views/ai/documentCollection/ChunkDocumentTable.vue b/easyflow-ui-admin/app/src/views/ai/documentCollection/ChunkDocumentTable.vue index bb26f7c0..0aee0147 100644 --- a/easyflow-ui-admin/app/src/views/ai/documentCollection/ChunkDocumentTable.vue +++ b/easyflow-ui-admin/app/src/views/ai/documentCollection/ChunkDocumentTable.vue @@ -153,6 +153,30 @@ const isSyncSuccessVisible = (row: any) => syncSuccessVersions.value[String(row?.id || '')] === String(row?.indexSyncVersion ?? ''); +const getSyncRetryLabel = (row: any) => { + const attempt = Number(row?.indexSyncAttemptCount); + const maxAttempts = Number(row?.indexSyncMaxAttempts); + if ( + row?.indexSyncStatus !== 'PENDING' || + !row.indexSyncErrorCode || + !Number.isInteger(attempt) || + attempt <= 0 || + !Number.isInteger(maxAttempts) || + maxAttempts <= 0 + ) { + return ''; + } + const retryKeys: Record = { + EMBEDDING_REQUEST_FAILED: 'chunkSyncEmbeddingRetry', + KEYWORD_UPSERT_FAILED: 'chunkSyncKeywordRetry', + VECTOR_UPSERT_FAILED: 'chunkSyncVectorRetry', + }; + return $t( + `documentCollection.${retryKeys[row.indexSyncErrorCode] || 'chunkSyncIndexRetry'}`, + { attempt, maxAttempts }, + ); +}; + const getChunkOptions = (row: any) => row?.options || {}; const getRawMarkdown = (row: any) => String(getChunkOptions(row)?.renderMarkdown ?? row?.content ?? ''); @@ -323,6 +347,8 @@ const updateRow = (row: any, updated: any) => { content: updated?.content ?? row.content, indexSyncErrorCode: updated?.indexSyncErrorCode ?? null, indexSyncErrorMessage: updated?.indexSyncErrorMessage ?? null, + indexSyncAttemptCount: updated?.indexSyncAttemptCount ?? null, + indexSyncMaxAttempts: updated?.indexSyncMaxAttempts ?? null, indexSyncStatus: nextSyncStatus, indexSyncVersion: nextSyncVersion, options: updated?.options ?? { @@ -390,7 +416,13 @@ const pollSyncStatus = async (generation: number) => { ) { continue; } - pageDataRef.value?.patchRowById?.(status.id, status); + pageDataRef.value?.patchRowById?.(status.id, { + ...status, + indexSyncAttemptCount: status.indexSyncAttemptCount ?? null, + indexSyncMaxAttempts: status.indexSyncMaxAttempts ?? null, + indexSyncErrorCode: status.indexSyncErrorCode ?? null, + indexSyncErrorMessage: status.indexSyncErrorMessage ?? null, + }); if (status.indexSyncStatus === 'SYNCED') { showSyncSuccessFeedback(status); } else { @@ -458,6 +490,8 @@ const retrySync = async (row: any) => { pageDataRef.value?.patchRowById?.(row.id, { indexSyncErrorCode: res.data?.indexSyncErrorCode ?? null, indexSyncErrorMessage: res.data?.indexSyncErrorMessage ?? null, + indexSyncAttemptCount: res.data?.indexSyncAttemptCount ?? null, + indexSyncMaxAttempts: res.data?.indexSyncMaxAttempts ?? null, indexSyncStatus: res.data?.indexSyncStatus, indexSyncVersion: res.data?.indexSyncVersion, }); @@ -785,6 +819,13 @@ const getChunkHeaderLabel = (row: any) => { + + {{ getSyncRetryLabel(row) }} +
{ {{ $t('button.delete') }}
+ + {{ getSyncRetryLabel(row) }} + @@ -1089,6 +1137,21 @@ const getChunkHeaderLabel = (row: any) => { gap: var(--space-1); } +.chunk-sync-retry-hint { + display: block; + min-width: 0; + margin-top: var(--space-1); + font-weight: normal; + color: hsl(var(--text-muted)); + text-align: right; + overflow-wrap: anywhere; +} + +.chunk-item > .chunk-sync-retry-hint { + margin-top: 0; + margin-bottom: var(--space-2); +} + .chunk-sync-status { display: inline-grid; flex: 0 0 24px;