mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 13:25:12 +00:00
feat(core): workspace embedding settings (#11801)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced "Indexer & Embedding" workspace settings to manage AI embedding for local content, including document ignoring and attachment uploads. - Added UI components for embedding settings, attachments, and ignored documents with pagination and deletion capabilities. - Provided comprehensive file-type icons for attachments. - **Improvements** - Added a new tab for indexing and embedding in workspace settings navigation. - Included test identifiers on key UI elements to enhance automated testing. - **Localization** - Added English localization strings covering all embedding-related UI text and actions. - **Bug Fixes** - Enabled previously skipped end-to-end tests for embedding settings to improve reliability. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -14,7 +14,7 @@ export type SettingTab =
|
||||
| 'editor'
|
||||
| 'account'
|
||||
| 'meetings'
|
||||
| `workspace:${'preference' | 'properties' | 'members' | 'storage' | 'billing' | 'license' | 'integrations'}`;
|
||||
| `workspace:${'preference' | 'properties' | 'members' | 'storage' | 'billing' | 'license' | 'integrations' | 'indexer-embedding' | 'search'}`;
|
||||
|
||||
export type GLOBAL_DIALOG_SCHEMA = {
|
||||
'create-workspace': (props: { serverId?: string }) => {
|
||||
|
||||
@@ -58,6 +58,7 @@ import { configureThemeEditorModule } from './theme-editor';
|
||||
import { configureUrlModule } from './url';
|
||||
import { configureUserspaceModule } from './userspace';
|
||||
import { configureWorkspaceModule } from './workspace';
|
||||
import { configureIndexerEmbeddingModule } from './workspace-indexer-embedding';
|
||||
import { configureWorkspacePropertyModule } from './workspace-property';
|
||||
|
||||
export function configureCommonModules(framework: Framework) {
|
||||
@@ -116,4 +117,5 @@ export function configureCommonModules(framework: Framework) {
|
||||
configureIntegrationModule(framework);
|
||||
configureWorkspacePropertyModule(framework);
|
||||
configureCollectionRulesModule(framework);
|
||||
configureIndexerEmbeddingModule(framework);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const COUNT_PER_PAGE = 10;
|
||||
@@ -0,0 +1,240 @@
|
||||
import { logger } from '@affine/core/modules/share-doc/entities/share-docs-list';
|
||||
import type { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import type { PaginationInput } from '@affine/graphql';
|
||||
import {
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
smartRetry,
|
||||
} from '@toeverything/infra';
|
||||
import { EMPTY } from 'rxjs';
|
||||
import { concatMap, exhaustMap, mergeMap } from 'rxjs/operators';
|
||||
|
||||
import { COUNT_PER_PAGE } from '../constants';
|
||||
import type { EmbeddingStore } from '../stores/embedding';
|
||||
import type { AttachmentFile, IgnoredDoc } from '../types';
|
||||
|
||||
export interface EmbeddingConfig {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface Attachments {
|
||||
totalCount: number;
|
||||
pageInfo: {
|
||||
endCursor: string | null;
|
||||
hasNextPage: boolean;
|
||||
};
|
||||
edges: {
|
||||
node: AttachmentFile;
|
||||
}[];
|
||||
}
|
||||
|
||||
type IgnoredDocs = IgnoredDoc[];
|
||||
|
||||
export class Embedding extends Entity {
|
||||
enabled$ = new LiveData<boolean>(false);
|
||||
error$ = new LiveData<any>(null);
|
||||
attachments$ = new LiveData<Attachments>({
|
||||
edges: [],
|
||||
pageInfo: {
|
||||
endCursor: null,
|
||||
hasNextPage: false,
|
||||
},
|
||||
totalCount: 0,
|
||||
});
|
||||
ignoredDocs$ = new LiveData<IgnoredDocs>([]);
|
||||
isEnabledLoading$ = new LiveData(false);
|
||||
isAttachmentsLoading$ = new LiveData(false);
|
||||
isIgnoredDocsLoading$ = new LiveData(false);
|
||||
|
||||
constructor(
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly store: EmbeddingStore
|
||||
) {
|
||||
super();
|
||||
this.getEnabled();
|
||||
this.getAttachments({ first: COUNT_PER_PAGE, after: null });
|
||||
this.getIgnoredDocs();
|
||||
}
|
||||
|
||||
getEnabled = effect(
|
||||
exhaustMap(() => {
|
||||
return fromPromise(signal =>
|
||||
this.store.getEnabled(this.workspaceService.workspace.id, signal)
|
||||
).pipe(
|
||||
smartRetry(),
|
||||
mergeMap(value => {
|
||||
this.enabled$.next(value);
|
||||
return EMPTY;
|
||||
}),
|
||||
catchErrorInto(this.error$, error => {
|
||||
logger.error(
|
||||
'Failed to fetch workspace doc embedding enabled',
|
||||
error
|
||||
);
|
||||
}),
|
||||
onStart(() => this.isEnabledLoading$.setValue(true)),
|
||||
onComplete(() => this.isEnabledLoading$.setValue(false))
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
setEnabled = effect(
|
||||
exhaustMap((enabled: boolean) => {
|
||||
return fromPromise(signal =>
|
||||
this.store.updateEnabled(
|
||||
this.workspaceService.workspace.id,
|
||||
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(
|
||||
exhaustMap(() => {
|
||||
return fromPromise(signal =>
|
||||
this.store.getIgnoredDocs(this.workspaceService.workspace.id, signal)
|
||||
).pipe(
|
||||
smartRetry(),
|
||||
mergeMap(value => {
|
||||
this.ignoredDocs$.next(value);
|
||||
return EMPTY;
|
||||
}),
|
||||
catchErrorInto(this.error$, error => {
|
||||
logger.error(
|
||||
'Failed to fetch workspace doc embedding ignored docs',
|
||||
error
|
||||
);
|
||||
}),
|
||||
onStart(() => this.isIgnoredDocsLoading$.setValue(true)),
|
||||
onComplete(() => this.isIgnoredDocsLoading$.setValue(false))
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
updateIgnoredDocs = effect(
|
||||
exhaustMap(({ add, remove }: { add: string[]; remove: string[] }) => {
|
||||
return fromPromise(signal =>
|
||||
this.store.updateIgnoredDocs(
|
||||
this.workspaceService.workspace.id,
|
||||
add,
|
||||
remove,
|
||||
signal
|
||||
)
|
||||
).pipe(
|
||||
smartRetry(),
|
||||
concatMap(() => {
|
||||
this.getIgnoredDocs();
|
||||
return EMPTY;
|
||||
}),
|
||||
catchErrorInto(this.error$, error => {
|
||||
logger.error(
|
||||
'Failed to update workspace doc embedding ignored docs',
|
||||
error
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
getAttachments = effect(
|
||||
exhaustMap((pagination: PaginationInput) => {
|
||||
return fromPromise(signal =>
|
||||
this.store.getEmbeddingFiles(
|
||||
this.workspaceService.workspace.id,
|
||||
pagination,
|
||||
signal
|
||||
)
|
||||
).pipe(
|
||||
smartRetry(),
|
||||
mergeMap(value => {
|
||||
this.attachments$.next(value);
|
||||
return EMPTY;
|
||||
}),
|
||||
catchErrorInto(this.error$, error => {
|
||||
logger.error(
|
||||
'Failed to fetch workspace doc embedding attachments',
|
||||
error
|
||||
);
|
||||
}),
|
||||
onStart(() => this.isAttachmentsLoading$.setValue(true)),
|
||||
onComplete(() => this.isAttachmentsLoading$.setValue(false))
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
addAttachments = effect(
|
||||
exhaustMap((files: File[]) => {
|
||||
return fromPromise(signal =>
|
||||
this.store.addEmbeddingFiles(
|
||||
this.workspaceService.workspace.id,
|
||||
files,
|
||||
signal
|
||||
)
|
||||
).pipe(
|
||||
concatMap(() => {
|
||||
this.getAttachments({ first: COUNT_PER_PAGE, after: null });
|
||||
return EMPTY;
|
||||
}),
|
||||
catchErrorInto(this.error$, error => {
|
||||
logger.error(
|
||||
'Failed to add workspace doc embedding attachments',
|
||||
error
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
removeAttachment = effect(
|
||||
exhaustMap((id: string) => {
|
||||
return fromPromise(signal =>
|
||||
this.store.removeEmbeddingFile(
|
||||
this.workspaceService.workspace.id,
|
||||
id,
|
||||
signal
|
||||
)
|
||||
).pipe(
|
||||
concatMap(() => {
|
||||
this.getAttachments({ first: COUNT_PER_PAGE, after: null });
|
||||
return EMPTY;
|
||||
}),
|
||||
catchErrorInto(this.error$, error => {
|
||||
logger.error(
|
||||
'Failed to remove workspace doc embedding attachment',
|
||||
error
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
override dispose(): void {
|
||||
this.getEnabled.unsubscribe();
|
||||
this.getAttachments.unsubscribe();
|
||||
this.getIgnoredDocs.unsubscribe();
|
||||
this.updateIgnoredDocs.unsubscribe();
|
||||
this.addAttachments.unsubscribe();
|
||||
this.removeAttachment.unsubscribe();
|
||||
this.setEnabled.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { WorkspaceServerService } from '@affine/core/modules/cloud';
|
||||
import {
|
||||
WorkspaceScope,
|
||||
WorkspaceService,
|
||||
} from '@affine/core/modules/workspace';
|
||||
import { type Framework } from '@toeverything/infra';
|
||||
|
||||
import { Embedding } from './entities/embedding';
|
||||
import { EmbeddingService } from './services/embedding';
|
||||
import { EmbeddingStore } from './stores/embedding';
|
||||
|
||||
export function configureIndexerEmbeddingModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(EmbeddingService)
|
||||
.store(EmbeddingStore, [WorkspaceServerService])
|
||||
.entity(Embedding, [WorkspaceService, EmbeddingStore]);
|
||||
}
|
||||
|
||||
export { IndexerEmbeddingSettings } from './view';
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Service } from '@toeverything/infra';
|
||||
|
||||
import { Embedding } from '../entities/embedding';
|
||||
|
||||
export class EmbeddingService extends Service {
|
||||
embedding = this.framework.createEntity(Embedding);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { WorkspaceServerService } from '@affine/core/modules/cloud';
|
||||
import {
|
||||
addWorkspaceEmbeddingFilesMutation,
|
||||
addWorkspaceEmbeddingIgnoredDocsMutation,
|
||||
getAllWorkspaceEmbeddingIgnoredDocsQuery,
|
||||
getWorkspaceConfigQuery,
|
||||
getWorkspaceEmbeddingFilesQuery,
|
||||
type PaginationInput,
|
||||
removeWorkspaceEmbeddingFilesMutation,
|
||||
removeWorkspaceEmbeddingIgnoredDocsMutation,
|
||||
setEnableDocEmbeddingMutation,
|
||||
} from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
export class EmbeddingStore extends Store {
|
||||
constructor(private readonly workspaceServerService: WorkspaceServerService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async getEnabled(workspaceId: string, signal?: AbortSignal) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const data = await this.workspaceServerService.server.gql({
|
||||
query: getWorkspaceConfigQuery,
|
||||
variables: {
|
||||
id: workspaceId,
|
||||
},
|
||||
context: {
|
||||
signal,
|
||||
},
|
||||
});
|
||||
return data.workspace.enableDocEmbedding;
|
||||
}
|
||||
|
||||
async updateEnabled(
|
||||
workspaceId: string,
|
||||
enabled: boolean,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
await this.workspaceServerService.server.gql({
|
||||
query: setEnableDocEmbeddingMutation,
|
||||
variables: {
|
||||
id: workspaceId,
|
||||
enableDocEmbedding: enabled,
|
||||
},
|
||||
context: {
|
||||
signal,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getIgnoredDocs(workspaceId: string, signal?: AbortSignal) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
|
||||
const data = await this.workspaceServerService.server.gql({
|
||||
query: getAllWorkspaceEmbeddingIgnoredDocsQuery,
|
||||
variables: {
|
||||
workspaceId,
|
||||
},
|
||||
context: { signal },
|
||||
});
|
||||
return data.workspace.embedding.allIgnoredDocs;
|
||||
}
|
||||
|
||||
async updateIgnoredDocs(
|
||||
workspaceId: string,
|
||||
add: string[],
|
||||
remove: string[],
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
this.workspaceServerService.server.gql({
|
||||
query: addWorkspaceEmbeddingIgnoredDocsMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
add,
|
||||
},
|
||||
context: { signal },
|
||||
}),
|
||||
this.workspaceServerService.server.gql({
|
||||
query: removeWorkspaceEmbeddingIgnoredDocsMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
remove,
|
||||
},
|
||||
context: { signal },
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
async addEmbeddingFile(
|
||||
workspaceId: string,
|
||||
blob: File,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
|
||||
await this.workspaceServerService.server.gql({
|
||||
query: addWorkspaceEmbeddingFilesMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
blob,
|
||||
},
|
||||
context: { signal },
|
||||
});
|
||||
}
|
||||
|
||||
async addEmbeddingFiles(
|
||||
workspaceId: string,
|
||||
files: File[],
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
for (const file of files) {
|
||||
await this.addEmbeddingFile(workspaceId, file, signal);
|
||||
}
|
||||
}
|
||||
|
||||
async removeEmbeddingFile(
|
||||
workspaceId: string,
|
||||
fileId: string,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
|
||||
await this.workspaceServerService.server.gql({
|
||||
query: removeWorkspaceEmbeddingFilesMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
fileId,
|
||||
},
|
||||
context: { signal },
|
||||
});
|
||||
}
|
||||
|
||||
async removeEmbeddingFiles(
|
||||
workspaceId: string,
|
||||
fileIds: string[],
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
for (const fileId of fileIds) {
|
||||
await this.removeEmbeddingFile(workspaceId, fileId, signal);
|
||||
}
|
||||
}
|
||||
|
||||
async getEmbeddingFiles(
|
||||
workspaceId: string,
|
||||
pagination: PaginationInput,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
|
||||
const data = await this.workspaceServerService.server.gql({
|
||||
query: getWorkspaceEmbeddingFilesQuery,
|
||||
variables: {
|
||||
workspaceId,
|
||||
pagination,
|
||||
},
|
||||
context: { signal },
|
||||
});
|
||||
return data.workspace.embedding.files;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface AttachmentFile {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface IgnoredDoc {
|
||||
docId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Loading, 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 { useCallback } from 'react';
|
||||
|
||||
import { COUNT_PER_PAGE } from '../constants';
|
||||
import type { AttachmentFile } from '../types';
|
||||
import {
|
||||
attachmentItem,
|
||||
attachmentOperation,
|
||||
attachmentsWrapper,
|
||||
attachmentTitle,
|
||||
} from './styles-css';
|
||||
|
||||
interface AttachmentsProps {
|
||||
attachments: AttachmentFile[];
|
||||
totalCount: number;
|
||||
isLoading: boolean;
|
||||
onPageChange: (offset: number) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
interface AttachmentItemProps {
|
||||
attachment: AttachmentFile;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
const AttachmentItem: React.FC<AttachmentItemProps> = ({
|
||||
attachment,
|
||||
onDelete,
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title:
|
||||
t[
|
||||
'com.affine.settings.workspace.indexer-embedding.embedding.additional-attachments.remove-attachment.title'
|
||||
](),
|
||||
description:
|
||||
t[
|
||||
'com.affine.settings.workspace.indexer-embedding.embedding.additional-attachments.remove-attachment.description'
|
||||
](),
|
||||
confirmText: t['Confirm'](),
|
||||
confirmButtonOptions: {
|
||||
variant: 'error',
|
||||
},
|
||||
onConfirm: () => {
|
||||
onDelete(attachment.fileId);
|
||||
},
|
||||
});
|
||||
}, [onDelete, attachment.fileId, openConfirmModal, t]);
|
||||
|
||||
const Icon = getAttachmentFileIconRC(attachment.mimeType);
|
||||
return (
|
||||
<div
|
||||
className={attachmentItem}
|
||||
data-testid="workspace-embedding-setting-attachment-item"
|
||||
>
|
||||
<div className={attachmentTitle}>
|
||||
<Icon style={{ marginRight: 4 }} /> {attachment.fileName}
|
||||
</div>
|
||||
<div className={attachmentOperation}>
|
||||
<CloseIcon
|
||||
data-testid="workspace-embedding-setting-attachment-delete-button"
|
||||
onClick={handleDelete}
|
||||
color={cssVarV2('icon/primary')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Attachments: React.FC<AttachmentsProps> = ({
|
||||
attachments,
|
||||
totalCount,
|
||||
isLoading,
|
||||
onDelete,
|
||||
onPageChange,
|
||||
}) => {
|
||||
const handlePageChange = useCallback(
|
||||
(offset: number) => {
|
||||
onPageChange(offset);
|
||||
},
|
||||
[onPageChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={attachmentsWrapper}
|
||||
data-testid="workspace-embedding-setting-attachment-list"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
attachments.map(attachment => (
|
||||
<AttachmentItem
|
||||
key={attachment.fileId}
|
||||
attachment={attachment}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<Pagination
|
||||
totalCount={totalCount}
|
||||
countPerPage={COUNT_PER_PAGE}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Button, Switch } from '@affine/component';
|
||||
import {
|
||||
SettingRow,
|
||||
SettingWrapper,
|
||||
} from '@affine/component/setting-components';
|
||||
import { Upload } from '@affine/core/components/pure/file-upload';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import type React from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { EmbeddingService } from '../services/embedding';
|
||||
import { Attachments } from './attachments';
|
||||
import { IgnoredDocs } from './ignored-docs';
|
||||
|
||||
interface EmbeddingSettingsProps {}
|
||||
|
||||
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 ignoredDocs = useLiveData(embeddingService.embedding.ignoredDocs$);
|
||||
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(
|
||||
(checked: boolean) => {
|
||||
embeddingService.embedding.setEnabled(checked);
|
||||
},
|
||||
[embeddingService.embedding]
|
||||
);
|
||||
|
||||
const handleAttachmentUpload = useCallback(
|
||||
(file: File) => {
|
||||
embeddingService.embedding.addAttachments([file]);
|
||||
},
|
||||
[embeddingService.embedding]
|
||||
);
|
||||
|
||||
const handleAttachmentsDelete = useCallback(
|
||||
(fileId: string) => {
|
||||
embeddingService.embedding.removeAttachment(fileId);
|
||||
},
|
||||
[embeddingService.embedding]
|
||||
);
|
||||
|
||||
const handleAttachmentsPageChange = useCallback(
|
||||
(offset: number) => {
|
||||
embeddingService.embedding.getAttachments({
|
||||
offset,
|
||||
after: attachments.pageInfo.endCursor,
|
||||
});
|
||||
},
|
||||
[embeddingService.embedding, attachments.pageInfo.endCursor]
|
||||
);
|
||||
|
||||
const handleSelectDoc = useCallback(() => {
|
||||
if (isIgnoredDocsLoading) {
|
||||
return;
|
||||
}
|
||||
const initialIds = ignoredDocNodes.map(doc => doc.docId);
|
||||
workspaceDialogService.open(
|
||||
'doc-selector',
|
||||
{
|
||||
init: initialIds,
|
||||
},
|
||||
selectedIds => {
|
||||
if (selectedIds === undefined) {
|
||||
return;
|
||||
}
|
||||
const add = selectedIds.filter(id => !initialIds?.includes(id));
|
||||
const remove = initialIds?.filter(id => !selectedIds.includes(id));
|
||||
embeddingService.embedding.updateIgnoredDocs({ add, remove });
|
||||
}
|
||||
);
|
||||
}, [
|
||||
ignoredDocNodes,
|
||||
isIgnoredDocsLoading,
|
||||
workspaceDialogService,
|
||||
embeddingService.embedding,
|
||||
]);
|
||||
|
||||
return (
|
||||
<SettingWrapper
|
||||
title={t[
|
||||
'com.affine.settings.workspace.indexer-embedding.embedding.title'
|
||||
]()}
|
||||
testId="workspace-embedding-setting-wrapper"
|
||||
>
|
||||
<SettingRow
|
||||
name=""
|
||||
desc={t[
|
||||
'com.affine.settings.workspace.indexer-embedding.embedding.description'
|
||||
]()}
|
||||
></SettingRow>
|
||||
<SettingRow
|
||||
name={t[
|
||||
'com.affine.settings.workspace.indexer-embedding.embedding.switch.title'
|
||||
]()}
|
||||
desc={t[
|
||||
'com.affine.settings.workspace.indexer-embedding.embedding.switch.description'
|
||||
]()}
|
||||
>
|
||||
<Switch
|
||||
data-testid="workspace-embedding-setting-switch"
|
||||
checked={embeddingEnabled}
|
||||
onChange={handleEmbeddingToggle}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
name={t[
|
||||
'com.affine.settings.workspace.indexer-embedding.embedding.additional-attachments.title'
|
||||
]()}
|
||||
desc={t[
|
||||
'com.affine.settings.workspace.indexer-embedding.embedding.additional-attachments.description'
|
||||
]()}
|
||||
>
|
||||
<Upload fileChange={handleAttachmentUpload}>
|
||||
<Button
|
||||
data-testid="workspace-embedding-setting-upload-button"
|
||||
variant="primary"
|
||||
>
|
||||
{t['Upload']()}
|
||||
</Button>
|
||||
</Upload>
|
||||
</SettingRow>
|
||||
|
||||
{attachmentNodes.length > 0 && (
|
||||
<Attachments
|
||||
attachments={attachmentNodes}
|
||||
isLoading={isAttachmentsLoading}
|
||||
onDelete={handleAttachmentsDelete}
|
||||
totalCount={attachments.totalCount}
|
||||
onPageChange={handleAttachmentsPageChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingRow
|
||||
name={t[
|
||||
'com.affine.settings.workspace.indexer-embedding.embedding.ignore-docs.title'
|
||||
]()}
|
||||
desc={t[
|
||||
'com.affine.settings.workspace.indexer-embedding.embedding.ignore-docs.description'
|
||||
]()}
|
||||
>
|
||||
<Button
|
||||
data-testid="workspace-embedding-setting-ignore-docs-button"
|
||||
variant="primary"
|
||||
onClick={handleSelectDoc}
|
||||
>
|
||||
{t[
|
||||
'com.affine.settings.workspace.indexer-embedding.embedding.select-doc'
|
||||
]()}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
|
||||
{ignoredDocNodes.length > 0 && (
|
||||
<IgnoredDocs
|
||||
ignoredDocs={ignoredDocNodes}
|
||||
isLoading={isIgnoredDocsLoading}
|
||||
/>
|
||||
)}
|
||||
</SettingWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Loading } from '@affine/component';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
|
||||
import { i18nTime } from '@affine/i18n';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import type React from 'react';
|
||||
|
||||
import type { IgnoredDoc } from '../types';
|
||||
import {
|
||||
docItem,
|
||||
docItemIcon,
|
||||
docItemInfo,
|
||||
docItemTitle,
|
||||
excludeDocsWrapper,
|
||||
} from './styles-css';
|
||||
|
||||
interface IgnoredDocsProps {
|
||||
ignoredDocs: IgnoredDoc[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
interface DocItemProps {
|
||||
doc: IgnoredDoc;
|
||||
}
|
||||
|
||||
const DocItem: React.FC<DocItemProps> = ({ doc }) => {
|
||||
const docDisplayService = useService(DocDisplayMetaService);
|
||||
const docService = useService(DocsService);
|
||||
const docTitle = docDisplayService.title$(doc.docId).value;
|
||||
const DocIcon = docDisplayService.icon$(doc.docId).value;
|
||||
const docRecord = useLiveData(docService.list.doc$(doc.docId));
|
||||
if (!docRecord) {
|
||||
return null;
|
||||
}
|
||||
const updatedDate = docRecord.meta$.value.updatedDate;
|
||||
const createdDate = docRecord.meta$.value.createDate;
|
||||
|
||||
const updateDate = updatedDate
|
||||
? i18nTime(updatedDate, {
|
||||
relative: true,
|
||||
})
|
||||
: '-';
|
||||
const createDate = createdDate
|
||||
? i18nTime(createdDate, {
|
||||
absolute: {
|
||||
accuracy: 'day',
|
||||
noYear: true,
|
||||
},
|
||||
})
|
||||
: '-';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={docItem}
|
||||
data-testid="workspace-embedding-setting-ignore-docs-list-item"
|
||||
>
|
||||
<div className={docItemTitle}>
|
||||
<DocIcon className={docItemIcon} />
|
||||
<span data-testid="ignore-doc-title">{docTitle}</span>
|
||||
</div>
|
||||
|
||||
<div className={docItemInfo}>
|
||||
<span>{updateDate}</span>
|
||||
<span>{createDate}</span>
|
||||
</div>
|
||||
|
||||
{/* <div className={docItemInfo}>
|
||||
<span>{doc.createdAt}</span>
|
||||
<Avatar name={doc.userName} url={doc.userAvatar} />
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const IgnoredDocs: React.FC<IgnoredDocsProps> = ({
|
||||
ignoredDocs,
|
||||
isLoading,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={excludeDocsWrapper}
|
||||
data-testid="workspace-embedding-setting-ignore-docs-list"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
ignoredDocs.map(doc => <DocItem key={doc.docId} doc={doc} />)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { SettingHeader } from '@affine/component/setting-components';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import type React from 'react';
|
||||
|
||||
import { EmbeddingSettings } from './embedding-settings';
|
||||
|
||||
export const IndexerEmbeddingSettings: React.FC = () => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingHeader
|
||||
title={t['com.affine.settings.workspace.indexer-embedding.title']()}
|
||||
subtitle={t[
|
||||
'com.affine.settings.workspace.indexer-embedding.description'
|
||||
]()}
|
||||
/>
|
||||
|
||||
<EmbeddingSettings />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
|
||||
export const attachmentsWrapper = css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
padding: '8px',
|
||||
gap: '4px',
|
||||
isolation: 'isolate',
|
||||
border: `1px solid ${cssVar('borderColor')}`,
|
||||
borderRadius: '8px',
|
||||
flexGrow: 0,
|
||||
marginBottom: '16px',
|
||||
});
|
||||
|
||||
export const attachmentItem = css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '4px',
|
||||
gap: '4px',
|
||||
border: `0.5px solid ${cssVar('borderColor')}`,
|
||||
borderRadius: '4px',
|
||||
flex: 'none',
|
||||
alignSelf: 'stretch',
|
||||
flexGrow: 0,
|
||||
});
|
||||
|
||||
export const attachmentTitle = css({
|
||||
fontSize: '14px',
|
||||
fontWeight: 400,
|
||||
color: cssVar('textPrimaryColor'),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
});
|
||||
|
||||
export const attachmentOperation = css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const excludeDocsWrapper = css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
padding: '8px',
|
||||
gap: '4px',
|
||||
isolation: 'isolate',
|
||||
border: `1px solid ${cssVar('borderColor')}`,
|
||||
borderRadius: '8px',
|
||||
flexGrow: 0,
|
||||
marginBottom: '8px',
|
||||
overflowY: 'auto',
|
||||
maxHeight: '508px',
|
||||
});
|
||||
|
||||
export const docItem = css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
alignSelf: 'stretch',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
backgroundColor: cssVar('hoverColor'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const docItemTitle = css({
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
color: cssVar('textPrimaryColor'),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const docItemIcon = css({
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
marginRight: '8px',
|
||||
color: cssVarV2('icon/primary'),
|
||||
});
|
||||
|
||||
export const docItemInfo = css({
|
||||
display: 'flex',
|
||||
fontSize: '12px',
|
||||
fontWeight: 400,
|
||||
color: cssVar('textSecondaryColor'),
|
||||
gap: '12px',
|
||||
alignItems: 'center',
|
||||
});
|
||||
Reference in New Issue
Block a user