feat(core): workspace attachment uploading & error (#12330)

### TL;DR

feat: optimize workspace attachment uploading & error display

![截屏2025-05-16 15.29.43.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/MyktQ6Qwc7H6TiRCFoYN/2408fe5e-e54d-44a8-882c-91e1b26bb660.png)

### 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:
yoyoyohamapi
2025-05-22 02:35:03 +00:00
parent 21ea65edc5
commit 45ed9038b6
8 changed files with 346 additions and 64 deletions

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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}
/>
)}

View File

@@ -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',