mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 04:48:53 +00:00
feat(core): workspace attachment uploading & error (#12330)
### TL;DR feat: optimize workspace attachment uploading & error display  ### What Changes #### Support for Workspace Attachment Uploading & Error Handling * Added support for three attachment states: uploading (local), upload failed (local error), and uploaded (persisted). The frontend UI now displays real-time upload progress and error messages. * Attachments that fail to upload can be deleted directly without confirmation. * Merged display of uploading and uploaded attachments for a smoother user experience. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Attachments now show real-time upload status including uploading, error, and uploaded states. - Users can remove failed (error) attachments instantly without confirmation. - Attachment list merges uploading and uploaded files, displaying up to 10 items. - **Bug Fixes** - Improved error handling and messaging for failed attachment uploads. - **Style** - Enhanced visual styling for error attachments with distinct colors and backgrounds. - **Tests** - Added tests simulating slow network uploads, upload failures, and direct removal of error attachments. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -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<void>();
|
||||
uploadingAttachments$ = new LiveData<LocalAttachmentFile[]>([]);
|
||||
mergedAttachments$ = new LiveData<AttachmentFile[]>([]);
|
||||
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={attachmentTitle}
|
||||
data-testid="workspace-embedding-setting-attachment-uploading-item"
|
||||
>
|
||||
<Loading />
|
||||
{attachment.fileName}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ErrorItem: React.FC<{ attachment: ErrorAttachmentFile }> = ({
|
||||
attachment,
|
||||
}) => {
|
||||
return (
|
||||
<Tooltip content={attachment.errorMessage}>
|
||||
<div
|
||||
className={attachmentTitle}
|
||||
data-testid="workspace-embedding-setting-attachment-error-item"
|
||||
>
|
||||
<WarningIcon />
|
||||
{attachment.fileName}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const PersistedItem: React.FC<{ attachment: PersistedAttachmentFile }> = ({
|
||||
attachment,
|
||||
}) => {
|
||||
const Icon = getAttachmentFileIconRC(attachment.mimeType);
|
||||
return (
|
||||
<div
|
||||
className={attachmentTitle}
|
||||
data-testid="workspace-embedding-setting-attachment-persisted-item"
|
||||
>
|
||||
<Icon style={{ marginRight: 4 }} /> {attachment.fileName}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AttachmentItem: React.FC<AttachmentItemProps> = ({
|
||||
attachment,
|
||||
onDelete,
|
||||
@@ -36,6 +92,11 @@ const AttachmentItem: React.FC<AttachmentItemProps> = ({
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (isErrorAttachment(attachment)) {
|
||||
onDelete(getAttachmentId(attachment));
|
||||
return;
|
||||
}
|
||||
|
||||
openConfirmModal({
|
||||
title:
|
||||
t[
|
||||
@@ -50,20 +111,25 @@ const AttachmentItem: React.FC<AttachmentItemProps> = ({
|
||||
variant: 'error',
|
||||
},
|
||||
onConfirm: () => {
|
||||
onDelete(attachment.fileId);
|
||||
onDelete(getAttachmentId(attachment));
|
||||
},
|
||||
});
|
||||
}, [onDelete, attachment.fileId, openConfirmModal, t]);
|
||||
}, [onDelete, attachment, openConfirmModal, t]);
|
||||
|
||||
const Icon = getAttachmentFileIconRC(attachment.mimeType);
|
||||
return (
|
||||
<div
|
||||
className={attachmentItem}
|
||||
className={clsx(attachmentItem, {
|
||||
[attachmentError]: isErrorAttachment(attachment),
|
||||
})}
|
||||
data-testid="workspace-embedding-setting-attachment-item"
|
||||
>
|
||||
<div className={attachmentTitle}>
|
||||
<Icon style={{ marginRight: 4 }} /> {attachment.fileName}
|
||||
</div>
|
||||
{isUploadingAttachment(attachment) ? (
|
||||
<UploadingItem attachment={attachment} />
|
||||
) : isErrorAttachment(attachment) ? (
|
||||
<ErrorItem attachment={attachment} />
|
||||
) : isPersistedAttachment(attachment) ? (
|
||||
<PersistedItem attachment={attachment} />
|
||||
) : null}
|
||||
<div className={attachmentOperation}>
|
||||
<CloseIcon
|
||||
data-testid="workspace-embedding-setting-attachment-delete-button"
|
||||
@@ -79,7 +145,6 @@ const AttachmentItem: React.FC<AttachmentItemProps> = ({
|
||||
export const Attachments: React.FC<AttachmentsProps> = ({
|
||||
attachments,
|
||||
totalCount,
|
||||
isLoading,
|
||||
onDelete,
|
||||
onPageChange,
|
||||
}) => {
|
||||
@@ -95,17 +160,13 @@ export const Attachments: React.FC<AttachmentsProps> = ({
|
||||
className={attachmentsWrapper}
|
||||
data-testid="workspace-embedding-setting-attachment-list"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
attachments.map(attachment => (
|
||||
<AttachmentItem
|
||||
key={attachment.fileId}
|
||||
attachment={attachment}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{attachments.map(attachment => (
|
||||
<AttachmentItem
|
||||
key={getAttachmentId(attachment)}
|
||||
attachment={attachment}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
<Pagination
|
||||
totalCount={totalCount}
|
||||
countPerPage={COUNT_PER_PAGE}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useI18n } from '@affine/i18n';
|
||||
import track from '@affine/track';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import type React from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { EmbeddingService } from '../services/embedding';
|
||||
import { Attachments } from './attachments';
|
||||
@@ -23,7 +23,12 @@ export const EmbeddingSettings: React.FC<EmbeddingSettingsProps> = () => {
|
||||
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<EmbeddingSettingsProps> = () => {
|
||||
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<EmbeddingSettingsProps> = () => {
|
||||
(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<EmbeddingSettingsProps> = () => {
|
||||
}
|
||||
);
|
||||
}, [
|
||||
ignoredDocNodes,
|
||||
ignoredDocs,
|
||||
isIgnoredDocsLoading,
|
||||
workspaceDialogService,
|
||||
embeddingService.embedding,
|
||||
@@ -174,12 +171,11 @@ export const EmbeddingSettings: React.FC<EmbeddingSettingsProps> = () => {
|
||||
</Upload>
|
||||
</SettingRow>
|
||||
|
||||
{attachmentNodes.length > 0 && (
|
||||
{attachments.length > 0 && (
|
||||
<Attachments
|
||||
attachments={attachmentNodes}
|
||||
isLoading={isAttachmentsLoading}
|
||||
attachments={attachments}
|
||||
onDelete={handleAttachmentsDelete}
|
||||
totalCount={attachments.totalCount}
|
||||
totalCount={totalCount}
|
||||
onPageChange={handleAttachmentsPageChange}
|
||||
/>
|
||||
)}
|
||||
@@ -203,9 +199,9 @@ export const EmbeddingSettings: React.FC<EmbeddingSettingsProps> = () => {
|
||||
</Button>
|
||||
</SettingRow>
|
||||
|
||||
{ignoredDocNodes.length > 0 && (
|
||||
{ignoredDocs.length > 0 && (
|
||||
<IgnoredDocs
|
||||
ignoredDocs={ignoredDocNodes}
|
||||
ignoredDocs={ignoredDocs}
|
||||
isLoading={isIgnoredDocsLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user