refactor(core): workspace mutation effect (#12488)

### TL;DR

* refactor: workspace embedding mutation effect
* tests: error display for workspace embedding
This commit is contained in:
yoyoyohamapi
2025-05-26 07:17:37 +00:00
parent da22391910
commit c06c72e108
6 changed files with 231 additions and 172 deletions

View File

@@ -11,15 +11,8 @@ import {
onStart, onStart,
smartRetry, smartRetry,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { EMPTY, interval, of, Subject } from 'rxjs'; import { EMPTY, interval, Subject } from 'rxjs';
import { import { exhaustMap, mergeMap, switchMap, takeUntil } from 'rxjs/operators';
concatMap,
exhaustMap,
mergeMap,
switchMap,
takeUntil,
tap,
} from 'rxjs/operators';
import { COUNT_PER_PAGE } from '../constants'; import { COUNT_PER_PAGE } from '../constants';
import type { EmbeddingStore } from '../stores/embedding'; import type { EmbeddingStore } from '../stores/embedding';
@@ -110,31 +103,13 @@ export class Embedding extends Entity {
}) })
); );
setEnabled = effect( setEnabled = (enabled: boolean) => {
exhaustMap((enabled: boolean) => { return this.store
return fromPromise(signal => .updateEnabled(this.workspaceService.workspace.id, enabled)
this.store.updateEnabled( .then(() => {
this.workspaceService.workspace.id, this.getEnabled();
enabled, });
signal };
)
).pipe(
smartRetry(),
concatMap(() => {
this.getEnabled();
return EMPTY;
}),
catchErrorInto(this.error$, error => {
logger.error(
'Failed to update workspace doc embedding enabled',
error
);
}),
onStart(() => this.isEnabledLoading$.setValue(true)),
onComplete(() => this.isEnabledLoading$.setValue(false))
);
})
);
getIgnoredDocs = effect( getIgnoredDocs = effect(
exhaustMap(() => { exhaustMap(() => {
@@ -158,30 +133,19 @@ export class Embedding extends Entity {
}) })
); );
updateIgnoredDocs = effect( updateIgnoredDocs = ({
exhaustMap(({ add, remove }: { add: string[]; remove: string[] }) => { add,
return fromPromise(signal => remove,
this.store.updateIgnoredDocs( }: {
this.workspaceService.workspace.id, add: string[];
add, remove: string[];
remove, }) => {
signal return this.store
) .updateIgnoredDocs(this.workspaceService.workspace.id, add, remove)
).pipe( .then(() => {
smartRetry(), this.getIgnoredDocs();
concatMap(() => { });
this.getIgnoredDocs(); };
return EMPTY;
}),
catchErrorInto(this.error$, error => {
logger.error(
'Failed to update workspace doc embedding ignored docs',
error
);
})
);
})
);
getAttachments = effect( getAttachments = effect(
exhaustMap((pagination: PaginationInput) => { exhaustMap((pagination: PaginationInput) => {
@@ -219,95 +183,60 @@ export class Embedding extends Entity {
}) })
); );
addAttachments = effect( addAttachments = (files: File[]) => {
// Support parallel upload const generateLocalId = () =>
mergeMap((files: File[]) => { Math.random().toString(36).slice(2) + Date.now();
const generateLocalId = () => const localAttachments: LocalAttachmentFile[] = files.map(file => ({
Math.random().toString(36).slice(2) + Date.now(); localId: generateLocalId(),
const localAttachments: LocalAttachmentFile[] = files.map(file => ({ fileName: file.name,
localId: generateLocalId(), mimeType: file.type,
fileName: file.name, size: file.size,
mimeType: file.type, createdAt: file.lastModified,
size: file.size, status: 'uploading',
createdAt: file.lastModified, }));
status: 'uploading',
}));
return of({ files, localAttachments }).pipe( this.uploadingAttachments$.next([
// Refresh uploading attachments immediately ...localAttachments,
tap(({ localAttachments }) => { ...this.uploadingAttachments$.value,
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 });
}),
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
);
})
);
})
);
removeAttachment = effect( this.store
exhaustMap((id: string) => { .addEmbeddingFiles(this.workspaceService.workspace.id, files)
const localIndex = this.uploadingAttachments$.value.findIndex( .then(() => {
att => att.localId === id
);
if (localIndex !== -1) {
this.uploadingAttachments$.next( this.uploadingAttachments$.next(
this.uploadingAttachments$.value.filter(att => att.localId !== id) this.uploadingAttachments$.value.filter(
att => !localAttachments.some(l => l.localId === att.localId)
)
); );
return EMPTY; this.getAttachments({ first: COUNT_PER_PAGE, after: null });
} })
return fromPromise(signal => .catch(error => {
this.store.removeEmbeddingFile( this.uploadingAttachments$.next(
this.workspaceService.workspace.id, this.uploadingAttachments$.value.map(att =>
id, localAttachments.some(l => l.localId === att.localId)
signal ? { ...att, status: 'error', errorMessage: String(error) }
) : att
).pipe( )
concatMap(() => { );
this.getAttachments({ first: COUNT_PER_PAGE, after: null }); });
return EMPTY; };
}),
catchErrorInto(this.error$, error => { removeAttachment = (id: string) => {
logger.error( const localIndex = this.uploadingAttachments$.value.findIndex(
'Failed to remove workspace doc embedding attachment', att => att.localId === id
error );
); if (localIndex !== -1) {
}) this.uploadingAttachments$.next(
this.uploadingAttachments$.value.filter(att => att.localId !== id)
); );
}) return Promise.resolve();
); }
return this.store
.removeEmbeddingFile(this.workspaceService.workspace.id, id)
.then(() => {
this.getAttachments({ first: COUNT_PER_PAGE, after: null });
});
};
startEmbeddingProgressPolling() { startEmbeddingProgressPolling() {
this.stopEmbeddingProgressPolling(); this.stopEmbeddingProgressPolling();
@@ -355,10 +284,6 @@ export class Embedding extends Entity {
this.getEnabled.unsubscribe(); this.getEnabled.unsubscribe();
this.getAttachments.unsubscribe(); this.getAttachments.unsubscribe();
this.getIgnoredDocs.unsubscribe(); this.getIgnoredDocs.unsubscribe();
this.updateIgnoredDocs.unsubscribe();
this.addAttachments.unsubscribe();
this.removeAttachment.unsubscribe();
this.setEnabled.unsubscribe();
this.stopEmbeddingProgress$.next(); this.stopEmbeddingProgress$.next();
this.getEmbeddingProgress.unsubscribe(); this.getEmbeddingProgress.unsubscribe();
} }

View File

@@ -1,4 +1,4 @@
import { Button, Switch } from '@affine/component'; import { Button, notify, Switch } from '@affine/component';
import { import {
SettingHeader, SettingHeader,
SettingRow, SettingRow,
@@ -6,6 +6,7 @@ import {
} from '@affine/component/setting-components'; } from '@affine/component/setting-components';
import { Upload } from '@affine/core/components/pure/file-upload'; import { Upload } from '@affine/core/components/pure/file-upload';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs'; import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { UserFriendlyError } from '@affine/error';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import track from '@affine/track'; import track from '@affine/track';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
@@ -48,9 +49,19 @@ export const EmbeddingSettings: React.FC<EmbeddingSettingsProps> = () => {
control: 'Workspace embedding', control: 'Workspace embedding',
option: checked ? 'on' : 'off', option: checked ? 'on' : 'off',
}); });
embeddingService.embedding.setEnabled(checked);
embeddingService.embedding.setEnabled(checked).catch(error => {
const err = UserFriendlyError.fromAny(error);
notify.error({
title:
t[
'com.affine.settings.workspace.indexer-embedding.embedding.switch.error'
](),
message: t[`error.${err.name}`](err.data),
});
});
}, },
[embeddingService.embedding] [embeddingService.embedding, t]
); );
const handleAttachmentUpload = useCallback( const handleAttachmentUpload = useCallback(
@@ -67,9 +78,18 @@ export const EmbeddingSettings: React.FC<EmbeddingSettingsProps> = () => {
const handleAttachmentsDelete = useCallback( const handleAttachmentsDelete = useCallback(
(fileId: string) => { (fileId: string) => {
embeddingService.embedding.removeAttachment(fileId); embeddingService.embedding.removeAttachment(fileId).catch(error => {
const err = UserFriendlyError.fromAny(error);
notify.error({
title:
t[
'com.affine.settings.workspace.indexer-embedding.embedding.remove-attachment.error'
](),
message: t[`error.${err.name}`](err.data),
});
});
}, },
[embeddingService.embedding] [embeddingService.embedding, t]
); );
const handleAttachmentsPageChange = useCallback( const handleAttachmentsPageChange = useCallback(
@@ -102,7 +122,18 @@ export const EmbeddingSettings: React.FC<EmbeddingSettingsProps> = () => {
}); });
const add = selectedIds.filter(id => !initialIds?.includes(id)); const add = selectedIds.filter(id => !initialIds?.includes(id));
const remove = initialIds?.filter(id => !selectedIds.includes(id)); const remove = initialIds?.filter(id => !selectedIds.includes(id));
embeddingService.embedding.updateIgnoredDocs({ add, remove }); embeddingService.embedding
.updateIgnoredDocs({ add, remove })
.catch(error => {
const err = UserFriendlyError.fromAny(error);
notify.error({
title:
t[
'com.affine.settings.workspace.indexer-embedding.embedding.update-ignored-docs.error'
](),
message: t[`error.${err.name}`](err.data),
});
});
} }
); );
}, [ }, [
@@ -110,6 +141,7 @@ export const EmbeddingSettings: React.FC<EmbeddingSettingsProps> = () => {
isIgnoredDocsLoading, isIgnoredDocsLoading,
workspaceDialogService, workspaceDialogService,
embeddingService.embedding, embeddingService.embedding,
t,
]); ]);
useEffect(() => { useEffect(() => {

View File

@@ -6247,6 +6247,18 @@ export function useAFFiNEI18N(): {
* `AI can call files embedded in the workspace.` * `AI can call files embedded in the workspace.`
*/ */
["com.affine.settings.workspace.indexer-embedding.embedding.switch.description"](): string; ["com.affine.settings.workspace.indexer-embedding.embedding.switch.description"](): string;
/**
* `Failed to update workspace doc embedding enabled`
*/
["com.affine.settings.workspace.indexer-embedding.embedding.switch.error"](): string;
/**
* `Failed to remove attachment from embedding`
*/
["com.affine.settings.workspace.indexer-embedding.embedding.remove-attachment.error"](): string;
/**
* `Failed to update ignored docs`
*/
["com.affine.settings.workspace.indexer-embedding.embedding.update-ignored-docs.error"](): string;
/** /**
* `Embedding progress` * `Embedding progress`
*/ */

View File

@@ -1560,6 +1560,9 @@
"com.affine.settings.workspace.indexer-embedding.embedding.select-doc": "Select doc", "com.affine.settings.workspace.indexer-embedding.embedding.select-doc": "Select doc",
"com.affine.settings.workspace.indexer-embedding.embedding.switch.title": "Workspace Embedding", "com.affine.settings.workspace.indexer-embedding.embedding.switch.title": "Workspace Embedding",
"com.affine.settings.workspace.indexer-embedding.embedding.switch.description": "AI can call files embedded in the workspace.", "com.affine.settings.workspace.indexer-embedding.embedding.switch.description": "AI can call files embedded in the workspace.",
"com.affine.settings.workspace.indexer-embedding.embedding.switch.error": "Failed to update workspace doc embedding enabled",
"com.affine.settings.workspace.indexer-embedding.embedding.remove-attachment.error": "Failed to remove attachment from embedding",
"com.affine.settings.workspace.indexer-embedding.embedding.update-ignored-docs.error": "Failed to update ignored docs",
"com.affine.settings.workspace.indexer-embedding.embedding.progress.title": "Embedding progress", "com.affine.settings.workspace.indexer-embedding.embedding.progress.title": "Embedding progress",
"com.affine.settings.workspace.indexer-embedding.embedding.progress.syncing": "Syncing", "com.affine.settings.workspace.indexer-embedding.embedding.progress.syncing": "Syncing",
"com.affine.settings.workspace.indexer-embedding.embedding.progress.synced": "Synced", "com.affine.settings.workspace.indexer-embedding.embedding.progress.synced": "Synced",

View File

@@ -43,6 +43,23 @@ test.describe('AISettings/Embedding', () => {
await utils.settings.waitForWorkspaceEmbeddingSwitchToBe(page, true); await utils.settings.waitForWorkspaceEmbeddingSwitchToBe(page, true);
}); });
test('should show error message if enable workspace embedding failed', async ({
loggedInPage: page,
utils,
}) => {
await utils.settings.enableWorkspaceEmbedding(page);
await utils.settings.disableWorkspaceEmbedding(page);
await utils.settings.waitForWorkspaceEmbeddingSwitchToBe(page, false);
await page.context().setOffline(true);
await utils.settings.enableWorkspaceEmbedding(page, false);
await expect(
page.getByText(/Failed to update workspace doc embedding enabled/i)
).toBeVisible();
await page.context().setOffline(false);
});
test('should show embedding progress', async ({ test('should show embedding progress', async ({
loggedInPage: page, loggedInPage: page,
utils, utils,
@@ -297,6 +314,36 @@ test.describe('AISettings/Embedding', () => {
await utils.settings.removeAttachment(page, 'document1.txt'); await utils.settings.removeAttachment(page, 'document1.txt');
}); });
test('should show error message if remove attachment failed', 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 utils.settings.uploadWorkspaceEmbedding(page, attachments);
const attachmentList = await page.getByTestId(
'workspace-embedding-setting-attachment-list'
);
await expect(
attachmentList.getByTestId('workspace-embedding-setting-attachment-item')
).toHaveCount(1);
await page.context().setOffline(true);
await utils.settings.clickRemoveAttachment(page, 'document1.txt');
await expect(
page.getByText(/Failed to remove attachment from embedding/i)
).toBeVisible();
await page.context().setOffline(false);
});
test('should support remove error attachment directly', async ({ test('should support remove error attachment directly', async ({
loggedInPage: page, loggedInPage: page,
utils, utils,
@@ -393,4 +440,23 @@ test.describe('AISettings/Embedding', () => {
expect(content).toMatch(/I dont know/i); expect(content).toMatch(/I dont know/i);
}).toPass({ timeout: 20000 }); }).toPass({ timeout: 20000 });
}); });
test('should show error message if update ignored docs failed', async ({
loggedInPage: page,
utils,
}) => {
await utils.settings.enableWorkspaceEmbedding(page);
await utils.settings.closeSettingsPanel(page);
await utils.editor.createDoc(page, 'Test Doc', 'HelloWorld');
// Ignore docs
await utils.settings.openSettingsPanel(page);
await page.context().setOffline(true);
await utils.settings.ignoreDocForEmbedding(page, 'Test Doc', false);
await expect(
page.getByText(/Failed to update ignored docs/i)
).toBeVisible();
await page.context().setOffline(false);
});
}); });

View File

@@ -46,20 +46,30 @@ export class SettingsPanelUtils {
await input.click(); await input.click();
} }
public static async enableWorkspaceEmbedding(page: Page) { public static async enableWorkspaceEmbedding(
page: Page,
waitForEnabled = true
) {
const enabled = await this.isWorkspaceEmbeddingEnabled(page); const enabled = await this.isWorkspaceEmbeddingEnabled(page);
if (!enabled) { if (!enabled) {
await this.toggleWorkspaceEmbedding(page); await this.toggleWorkspaceEmbedding(page);
} }
await this.waitForWorkspaceEmbeddingSwitchToBe(page, true); if (waitForEnabled) {
await this.waitForWorkspaceEmbeddingSwitchToBe(page, true);
}
} }
public static async disableWorkspaceEmbedding(page: Page) { public static async disableWorkspaceEmbedding(
page: Page,
waitForDisabled = true
) {
const enabled = await this.isWorkspaceEmbeddingEnabled(page); const enabled = await this.isWorkspaceEmbeddingEnabled(page);
if (enabled) { if (enabled) {
await this.toggleWorkspaceEmbedding(page); await this.toggleWorkspaceEmbedding(page);
} }
await this.waitForWorkspaceEmbeddingSwitchToBe(page, false); if (waitForDisabled) {
await this.waitForWorkspaceEmbeddingSwitchToBe(page, false);
}
} }
public static async uploadWorkspaceEmbedding( public static async uploadWorkspaceEmbedding(
@@ -102,7 +112,7 @@ export class SettingsPanelUtils {
} }
} }
public static async removeAttachment( public static async clickRemoveAttachment(
page: Page, page: Page,
attachment: string, attachment: string,
shouldConfirm = true shouldConfirm = true
@@ -116,6 +126,14 @@ export class SettingsPanelUtils {
if (shouldConfirm) { if (shouldConfirm) {
await page.getByTestId('confirm-modal-confirm').click(); await page.getByTestId('confirm-modal-confirm').click();
} }
}
public static async removeAttachment(
page: Page,
attachment: string,
shouldConfirm = true
) {
await this.clickRemoveAttachment(page, attachment, shouldConfirm);
await page await page
.getByTestId('workspace-embedding-setting-attachment-item') .getByTestId('workspace-embedding-setting-attachment-item')
.filter({ hasText: attachment }) .filter({ hasText: attachment })
@@ -124,7 +142,11 @@ export class SettingsPanelUtils {
}); });
} }
public static async ignoreDocForEmbedding(page: Page, doc: string) { public static async ignoreDocForEmbedding(
page: Page,
doc: string,
shouldWaitForRefresh = true
) {
// Open Dos Searcher // Open Dos Searcher
const ignoreDocsButton = await page.getByTestId( const ignoreDocsButton = await page.getByTestId(
'workspace-embedding-setting-ignore-docs-button' 'workspace-embedding-setting-ignore-docs-button'
@@ -137,25 +159,24 @@ export class SettingsPanelUtils {
await searchInput.focus(); await searchInput.focus();
await page.keyboard.insertText(doc); await page.keyboard.insertText(doc);
const pageListItem = searcher.getByTestId('page-list-item'); const pageListItem = searcher.getByTestId('doc-list-item');
await expect(pageListItem).toHaveCount(1); await expect(pageListItem).toHaveCount(1);
const pageListItemTitle = pageListItem.getByTestId( const pageListItemTitle = pageListItem.getByTestId('doc-list-item-title');
'page-list-item-title-text'
);
await expect(pageListItemTitle).toHaveText(doc); await expect(pageListItemTitle).toHaveText(doc);
await pageListItem.click();
await pageListItem.getByTestId('affine-checkbox').check();
await searcher.getByTestId('doc-selector-confirm-button').click(); await searcher.getByTestId('doc-selector-confirm-button').click();
const ignoredDocs = await page.getByTestId( if (shouldWaitForRefresh) {
'workspace-embedding-setting-ignore-docs-list' const ignoredDocs = await page.getByTestId(
); 'workspace-embedding-setting-ignore-docs-list'
await expect( );
ignoredDocs await expect(
.getByTestId('workspace-embedding-setting-ignore-docs-list-item') ignoredDocs
.filter({ hasText: doc }) .getByTestId('workspace-embedding-setting-ignore-docs-list-item')
).toBeVisible(); .filter({ hasText: doc })
).toBeVisible();
}
} }
public static async clearAllIgnoredDocs(page: Page) { public static async clearAllIgnoredDocs(page: Page) {