diff --git a/packages/frontend/core/src/modules/workspace-indexer-embedding/entities/embedding.ts b/packages/frontend/core/src/modules/workspace-indexer-embedding/entities/embedding.ts index d28217e592..2c4f31841c 100644 --- a/packages/frontend/core/src/modules/workspace-indexer-embedding/entities/embedding.ts +++ b/packages/frontend/core/src/modules/workspace-indexer-embedding/entities/embedding.ts @@ -1,5 +1,5 @@ -import { logger } from '@affine/core/modules/share-doc/entities/share-docs-list'; import type { WorkspaceService } from '@affine/core/modules/workspace'; +import { DebugLogger } from '@affine/debug'; import type { PaginationInput } from '@affine/graphql'; import { catchErrorInto, @@ -11,18 +11,26 @@ import { onStart, smartRetry, } from '@toeverything/infra'; -import { EMPTY, interval, Subject } from 'rxjs'; +import { EMPTY, interval, of, Subject } from 'rxjs'; import { concatMap, exhaustMap, mergeMap, switchMap, takeUntil, + tap, } from 'rxjs/operators'; import { COUNT_PER_PAGE } from '../constants'; import type { EmbeddingStore } from '../stores/embedding'; -import type { AttachmentFile, IgnoredDoc } from '../types'; +import type { + AttachmentFile, + IgnoredDoc, + LocalAttachmentFile, + PersistedAttachmentFile, +} from '../types'; + +const logger = new DebugLogger('WorkspaceEmbedding'); export interface EmbeddingConfig { enabled: boolean; @@ -35,7 +43,7 @@ interface Attachments { hasNextPage: boolean; }; edges: { - node: AttachmentFile; + node: PersistedAttachmentFile; }[]; } @@ -66,6 +74,8 @@ export class Embedding extends Entity { private readonly EMBEDDING_PROGRESS_POLL_INTERVAL = 3000; private readonly stopEmbeddingProgress$ = new Subject(); + uploadingAttachments$ = new LiveData([]); + mergedAttachments$ = new LiveData([]); constructor( private readonly workspaceService: WorkspaceService, @@ -76,6 +86,15 @@ export class Embedding extends Entity { this.getAttachments({ first: COUNT_PER_PAGE, after: null }); this.getIgnoredDocs(); this.getEmbeddingProgress(); + this.uploadingAttachments$.subscribe(() => this.updateMergedAttachments()); + this.attachments$.subscribe(() => this.updateMergedAttachments()); + this.updateMergedAttachments(); + } + + private updateMergedAttachments() { + const uploading = this.uploadingAttachments$.value; + const uploaded = this.attachments$.value.edges.map(edge => edge.node); + this.mergedAttachments$.next([...uploading, ...uploaded].slice(0, 10)); } getEnabled = effect( @@ -184,7 +203,17 @@ export class Embedding extends Entity { ).pipe( smartRetry(), mergeMap(value => { - this.attachments$.next(value); + const patched = { + ...value, + edges: value.edges.map(edge => ({ + ...edge, + node: { + ...edge.node, + status: 'uploaded' as const, + }, + })), + }; + this.attachments$.next(patched); return EMPTY; }), catchErrorInto(this.error$, error => { @@ -200,19 +229,54 @@ export class Embedding extends Entity { ); addAttachments = effect( - exhaustMap((files: File[]) => { - return fromPromise(signal => - this.store.addEmbeddingFiles( - this.workspaceService.workspace.id, - files, - signal - ) - ).pipe( - concatMap(() => { + // Support parallel upload + mergeMap((files: File[]) => { + const generateLocalId = () => + Math.random().toString(36).slice(2) + Date.now(); + const localAttachments: LocalAttachmentFile[] = files.map(file => ({ + localId: generateLocalId(), + fileName: file.name, + mimeType: file.type, + size: file.size, + createdAt: file.lastModified, + status: 'uploading', + })); + + return of({ files, localAttachments }).pipe( + // Refresh uploading attachments immediately + tap(({ localAttachments }) => { + this.uploadingAttachments$.next([ + ...localAttachments, + ...this.uploadingAttachments$.value, + ]); + }), + // Uploading embedding files + switchMap(({ files }) => { + return fromPromise(signal => + this.store.addEmbeddingFiles( + this.workspaceService.workspace.id, + files, + signal + ) + ); + }), + // Refresh uploading attachments + tap(() => { + this.uploadingAttachments$.next( + this.uploadingAttachments$.value.filter( + att => !localAttachments.some(l => l.localId === att.localId) + ) + ); this.getAttachments({ first: COUNT_PER_PAGE, after: null }); - return EMPTY; }), catchErrorInto(this.error$, error => { + this.uploadingAttachments$.next( + this.uploadingAttachments$.value.map(att => + localAttachments.some(l => l.localId === att.localId) + ? { ...att, status: 'error', errorMessage: String(error) } + : att + ) + ); logger.error( 'Failed to add workspace doc embedding attachments', error @@ -224,6 +288,15 @@ export class Embedding extends Entity { removeAttachment = effect( exhaustMap((id: string) => { + const localIndex = this.uploadingAttachments$.value.findIndex( + att => att.localId === id + ); + if (localIndex !== -1) { + this.uploadingAttachments$.next( + this.uploadingAttachments$.value.filter(att => att.localId !== id) + ); + return EMPTY; + } return fromPromise(signal => this.store.removeEmbeddingFile( this.workspaceService.workspace.id, diff --git a/packages/frontend/core/src/modules/workspace-indexer-embedding/types.ts b/packages/frontend/core/src/modules/workspace-indexer-embedding/types.ts index 44dd5bffe9..c04cb21572 100644 --- a/packages/frontend/core/src/modules/workspace-indexer-embedding/types.ts +++ b/packages/frontend/core/src/modules/workspace-indexer-embedding/types.ts @@ -1,11 +1,32 @@ -export interface AttachmentFile { +export interface PersistedAttachmentFile { fileId: string; fileName: string; mimeType: string; size: number; createdAt: string; + status: 'uploaded'; } +export interface LocalAttachmentFile { + localId: string; + fileName: string; + mimeType: string; + size: number; + createdAt: number; + status: 'uploading' | 'error'; +} + +export interface UploadingAttachmentFile extends LocalAttachmentFile { + status: 'uploading'; +} + +export interface ErrorAttachmentFile extends LocalAttachmentFile { + status: 'error'; + errorMessage: string; +} + +export type AttachmentFile = PersistedAttachmentFile | LocalAttachmentFile; + export interface IgnoredDoc { docId: string; createdAt: string; diff --git a/packages/frontend/core/src/modules/workspace-indexer-embedding/utils.ts b/packages/frontend/core/src/modules/workspace-indexer-embedding/utils.ts new file mode 100644 index 0000000000..927e9fdc5a --- /dev/null +++ b/packages/frontend/core/src/modules/workspace-indexer-embedding/utils.ts @@ -0,0 +1,38 @@ +import type { + AttachmentFile, + ErrorAttachmentFile, + LocalAttachmentFile, + PersistedAttachmentFile, + UploadingAttachmentFile, +} from './types'; + +export function isPersistedAttachment( + attachment: AttachmentFile +): attachment is PersistedAttachmentFile { + return 'fileId' in attachment; +} + +export function isErrorAttachment( + attachment: AttachmentFile +): attachment is ErrorAttachmentFile { + return 'errorMessage' in attachment; +} + +export function isUploadingAttachment( + attachment: AttachmentFile +): attachment is UploadingAttachmentFile { + return 'localId' in attachment && attachment.status === 'uploading'; +} + +export function isLocalAttachment( + attachment: AttachmentFile +): attachment is LocalAttachmentFile { + return 'localId' in attachment; +} + +export function getAttachmentId(attachment: AttachmentFile): string { + if (isPersistedAttachment(attachment)) { + return attachment.fileId; + } + return attachment.localId; +} diff --git a/packages/frontend/core/src/modules/workspace-indexer-embedding/view/attachments.tsx b/packages/frontend/core/src/modules/workspace-indexer-embedding/view/attachments.tsx index c719f866eb..40b9a7dc79 100644 --- a/packages/frontend/core/src/modules/workspace-indexer-embedding/view/attachments.tsx +++ b/packages/frontend/core/src/modules/workspace-indexer-embedding/view/attachments.tsx @@ -1,14 +1,27 @@ -import { Loading, useConfirmModal } from '@affine/component'; +import { Loading, Tooltip, useConfirmModal } from '@affine/component'; import { Pagination } from '@affine/component/setting-components'; import { useI18n } from '@affine/i18n'; import { getAttachmentFileIconRC } from '@blocksuite/affine/components/icons'; import { cssVarV2 } from '@blocksuite/affine/shared/theme'; -import { CloseIcon } from '@blocksuite/icons/rc'; +import { CloseIcon, WarningIcon } from '@blocksuite/icons/rc'; +import clsx from 'clsx'; import { useCallback } from 'react'; import { COUNT_PER_PAGE } from '../constants'; -import type { AttachmentFile } from '../types'; +import type { + AttachmentFile, + ErrorAttachmentFile, + PersistedAttachmentFile, + UploadingAttachmentFile, +} from '../types'; import { + getAttachmentId, + isErrorAttachment, + isPersistedAttachment, + isUploadingAttachment, +} from '../utils'; +import { + attachmentError, attachmentItem, attachmentOperation, attachmentsWrapper, @@ -18,7 +31,6 @@ import { interface AttachmentsProps { attachments: AttachmentFile[]; totalCount: number; - isLoading: boolean; onPageChange: (offset: number) => void; onDelete: (id: string) => void; } @@ -28,6 +40,50 @@ interface AttachmentItemProps { onDelete: (id: string) => void; } +const UploadingItem: React.FC<{ attachment: UploadingAttachmentFile }> = ({ + attachment, +}) => { + return ( +
+ + {attachment.fileName} +
+ ); +}; + +const ErrorItem: React.FC<{ attachment: ErrorAttachmentFile }> = ({ + attachment, +}) => { + return ( + +
+ + {attachment.fileName} +
+
+ ); +}; + +const PersistedItem: React.FC<{ attachment: PersistedAttachmentFile }> = ({ + attachment, +}) => { + const Icon = getAttachmentFileIconRC(attachment.mimeType); + return ( +
+ {attachment.fileName} +
+ ); +}; + const AttachmentItem: React.FC = ({ attachment, onDelete, @@ -36,6 +92,11 @@ const AttachmentItem: React.FC = ({ const { openConfirmModal } = useConfirmModal(); const handleDelete = useCallback(() => { + if (isErrorAttachment(attachment)) { + onDelete(getAttachmentId(attachment)); + return; + } + openConfirmModal({ title: t[ @@ -50,20 +111,25 @@ const AttachmentItem: React.FC = ({ variant: 'error', }, onConfirm: () => { - onDelete(attachment.fileId); + onDelete(getAttachmentId(attachment)); }, }); - }, [onDelete, attachment.fileId, openConfirmModal, t]); + }, [onDelete, attachment, openConfirmModal, t]); - const Icon = getAttachmentFileIconRC(attachment.mimeType); return (
-
- {attachment.fileName} -
+ {isUploadingAttachment(attachment) ? ( + + ) : isErrorAttachment(attachment) ? ( + + ) : isPersistedAttachment(attachment) ? ( + + ) : null}
= ({ export const Attachments: React.FC = ({ attachments, totalCount, - isLoading, onDelete, onPageChange, }) => { @@ -95,17 +160,13 @@ export const Attachments: React.FC = ({ className={attachmentsWrapper} data-testid="workspace-embedding-setting-attachment-list" > - {isLoading ? ( - - ) : ( - attachments.map(attachment => ( - - )) - )} + {attachments.map(attachment => ( + + ))} = () => { const t = useI18n(); const embeddingService = useService(EmbeddingService); const embeddingEnabled = useLiveData(embeddingService.embedding.enabled$); - const attachments = useLiveData(embeddingService.embedding.attachments$); + const { pageInfo, totalCount } = useLiveData( + embeddingService.embedding.attachments$ + ); + const attachments = useLiveData( + embeddingService.embedding.mergedAttachments$ + ); const ignoredDocs = useLiveData(embeddingService.embedding.ignoredDocs$); const embeddingProgress = useLiveData( embeddingService.embedding.embeddingProgress$ @@ -32,14 +37,6 @@ export const EmbeddingSettings: React.FC = () => { const isIgnoredDocsLoading = useLiveData( embeddingService.embedding.isIgnoredDocsLoading$ ); - const isAttachmentsLoading = useLiveData( - embeddingService.embedding.isAttachmentsLoading$ - ); - const attachmentNodes = useMemo( - () => attachments.edges.map(edge => edge.node), - [attachments] - ); - const ignoredDocNodes = ignoredDocs; const workspaceDialogService = useService(WorkspaceDialogService); const handleEmbeddingToggle = useCallback( @@ -77,17 +74,17 @@ export const EmbeddingSettings: React.FC = () => { (offset: number) => { embeddingService.embedding.getAttachments({ offset, - after: attachments.pageInfo.endCursor, + after: pageInfo.endCursor, }); }, - [embeddingService.embedding, attachments.pageInfo.endCursor] + [embeddingService.embedding, pageInfo.endCursor] ); const handleSelectDoc = useCallback(() => { if (isIgnoredDocsLoading) { return; } - const initialIds = ignoredDocNodes.map(doc => doc.docId); + const initialIds = ignoredDocs.map(doc => doc.docId); workspaceDialogService.open( 'doc-selector', { @@ -108,7 +105,7 @@ export const EmbeddingSettings: React.FC = () => { } ); }, [ - ignoredDocNodes, + ignoredDocs, isIgnoredDocsLoading, workspaceDialogService, embeddingService.embedding, @@ -174,12 +171,11 @@ export const EmbeddingSettings: React.FC = () => { - {attachmentNodes.length > 0 && ( + {attachments.length > 0 && ( )} @@ -203,9 +199,9 @@ export const EmbeddingSettings: React.FC = () => { - {ignoredDocNodes.length > 0 && ( + {ignoredDocs.length > 0 && ( )} diff --git a/packages/frontend/core/src/modules/workspace-indexer-embedding/view/styles-css.ts b/packages/frontend/core/src/modules/workspace-indexer-embedding/view/styles-css.ts index e5153ad68e..8d0a0abdc5 100644 --- a/packages/frontend/core/src/modules/workspace-indexer-embedding/view/styles-css.ts +++ b/packages/frontend/core/src/modules/workspace-indexer-embedding/view/styles-css.ts @@ -23,6 +23,7 @@ export const attachmentItem = css({ alignItems: 'center', padding: '4px', gap: '4px', + color: cssVar('textPrimaryColor'), border: `0.5px solid ${cssVar('borderColor')}`, borderRadius: '4px', flex: 'none', @@ -33,12 +34,16 @@ export const attachmentItem = css({ export const attachmentTitle = css({ fontSize: '14px', fontWeight: 400, - color: cssVar('textPrimaryColor'), display: 'flex', alignItems: 'center', gap: '4px', }); +export const attachmentError = css({ + color: cssVarV2('status/error'), + backgroundColor: cssVarV2('layer/background/error'), +}); + export const attachmentOperation = css({ display: 'flex', alignItems: 'center', diff --git a/tests/affine-cloud-copilot/e2e/settings/embedding.spec.ts b/tests/affine-cloud-copilot/e2e/settings/embedding.spec.ts index 86c3486dc6..3bd9c8ac6c 100644 --- a/tests/affine-cloud-copilot/e2e/settings/embedding.spec.ts +++ b/tests/affine-cloud-copilot/e2e/settings/embedding.spec.ts @@ -82,15 +82,42 @@ test.describe('AISettings/Embedding', () => { buffer: buffer2, }, ]; + + const client = await page.context().newCDPSession(page); + await client.send('Network.enable'); + await client.send('Network.emulateNetworkConditions', { + offline: false, + latency: 1000, + downloadThroughput: (50 * 1024) / 8, + uploadThroughput: (50 * 1024) / 8, + connectionType: 'cellular3g', + }); + await utils.settings.uploadWorkspaceEmbedding(page, attachments); const attachmentList = await page.getByTestId( 'workspace-embedding-setting-attachment-list' ); + + // Uploading + await expect( + attachmentList.getByTestId( + 'workspace-embedding-setting-attachment-uploading-item' + ) + ).toHaveCount(2); + + // Persisted await expect( attachmentList.getByTestId('workspace-embedding-setting-attachment-item') ).toHaveCount(2); + await client.send('Network.emulateNetworkConditions', { + offline: false, + latency: 0, + downloadThroughput: -1, + uploadThroughput: -1, + }); + await utils.settings.closeSettingsPanel(page); await page.waitForTimeout(5000); // wait for the embedding to be ready @@ -120,6 +147,36 @@ test.describe('AISettings/Embedding', () => { }).toPass({ timeout: 20000 }); }); + test('should display failed info if upload attachment failed', async ({ + loggedInPage: page, + utils, + }) => { + await utils.settings.enableWorkspaceEmbedding(page); + const attachments = [ + { + name: 'document1.txt', + mimeType: 'text/plain', + buffer: Buffer.from('HelloWorld'), + }, + ]; + + await page.context().setOffline(true); + + await utils.settings.uploadWorkspaceEmbedding(page, attachments); + + const attachmentList = await page.getByTestId( + 'workspace-embedding-setting-attachment-list' + ); + + const errorItem = await attachmentList.getByTestId( + 'workspace-embedding-setting-attachment-error-item' + ); + await errorItem.hover(); + await expect(page.getByText(/Network error/i)).toBeVisible(); + + await page.context().setOffline(false); + }); + test('should support hybrid search for both globally uploaded attachments and those uploaded in the current session', async ({ loggedInPage: page, utils, @@ -216,7 +273,7 @@ test.describe('AISettings/Embedding', () => { ).toHaveText('document1.txt'); }); - test('should support remove attachment', async ({ + test('should support remove attachment with confirm', async ({ loggedInPage: page, utils, }) => { @@ -240,6 +297,25 @@ test.describe('AISettings/Embedding', () => { await utils.settings.removeAttachment(page, 'document1.txt'); }); + test('should support remove error attachment directly', async ({ + loggedInPage: page, + utils, + }) => { + await utils.settings.enableWorkspaceEmbedding(page); + const textContent = 'WorkspaceEBEEE is a cute cat'; + const attachments = [ + { + name: 'document1.txt', + mimeType: 'text/plain', + buffer: Buffer.from(textContent), + }, + ]; + await page.context().setOffline(true); + await utils.settings.uploadWorkspaceEmbedding(page, attachments); + await utils.settings.removeAttachment(page, 'document1.txt', false); + await page.context().setOffline(false); + }); + // FIXME: wait for indexer test.skip('should support ignore docs for embedding', async ({ loggedInPage: page, diff --git a/tests/affine-cloud-copilot/e2e/utils/settings-panel-utils.ts b/tests/affine-cloud-copilot/e2e/utils/settings-panel-utils.ts index 1a178423f3..c7d8008c1b 100644 --- a/tests/affine-cloud-copilot/e2e/utils/settings-panel-utils.ts +++ b/tests/affine-cloud-copilot/e2e/utils/settings-panel-utils.ts @@ -87,23 +87,35 @@ export class SettingsPanelUtils { while (count > 0) { const attachmentItem = await page.getByTestId(itemId).first(); + const hasErrorItem = await attachmentItem + .getByTestId('workspace-embedding-setting-attachment-error-item') + .isVisible(); await attachmentItem .getByTestId('workspace-embedding-setting-attachment-delete-button') .click(); - await page.getByTestId('confirm-modal-confirm').click(); + + if (!hasErrorItem) { + await page.getByTestId('confirm-modal-confirm').click(); + } await page.waitForTimeout(1000); count = await page.getByTestId(itemId).count(); } } - public static async removeAttachment(page: Page, attachment: string) { + public static async removeAttachment( + page: Page, + attachment: string, + shouldConfirm = true + ) { const attachmentItem = await page .getByTestId('workspace-embedding-setting-attachment-item') .filter({ hasText: attachment }); await attachmentItem .getByTestId('workspace-embedding-setting-attachment-delete-button') .click(); - await page.getByTestId('confirm-modal-confirm').click(); + if (shouldConfirm) { + await page.getByTestId('confirm-modal-confirm').click(); + } await page .getByTestId('workspace-embedding-setting-attachment-item') .filter({ hasText: attachment })