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:
yoyoyohamapi
2025-05-15 09:36:28 +00:00
parent 6224344a4f
commit 6c9f28e08b
25 changed files with 1266 additions and 86 deletions

View File

@@ -1081,6 +1081,7 @@ jobs:
- 'packages/backend/server/src/plugins/copilot/**'
- 'packages/backend/server/tests/copilot.*'
- 'packages/frontend/core/src/blocksuite/ai/**'
- 'packages/frontend/core/src/modules/workspace-indexer-embedding/**'
- 'tests/affine-cloud-copilot/**'
- name: Setup Node.js

View File

@@ -0,0 +1,173 @@
import {
FileIconAepIcon,
FileIconAiIcon,
FileIconAviIcon,
FileIconCssIcon,
FileIconCsvIcon,
FileIconDmgIcon,
FileIconDocIcon,
FileIconDocxIcon,
FileIconEpsIcon,
FileIconExeIcon,
FileIconFigIcon,
FileIconGifIcon,
FileIconHtmlIcon,
FileIconInddIcon,
FileIconJavaIcon,
FileIconJpegIcon,
FileIconJsIcon,
FileIconJsonIcon,
FileIconMkvIcon,
FileIconMp3Icon,
FileIconMp4Icon,
FileIconMpegIcon,
FileIconNoneIcon,
FileIconPdfIcon,
FileIconPngIcon,
FileIconPptIcon,
FileIconPptxIcon,
FileIconPsdIcon,
FileIconRarIcon,
FileIconRssIcon,
FileIconSqlIcon,
FileIconSvgIcon,
FileIconTiffIcon,
FileIconTxtIcon,
FileIconWavIcon,
FileIconWebpIcon,
FileIconXlsIcon,
FileIconXlsxIcon,
FileIconXmlIcon,
FileIconZipIcon,
ImageIcon,
} from '@blocksuite/icons/rc';
export function getAttachmentFileIconRC(filetype: string) {
switch (filetype) {
case 'img':
return ImageIcon;
case 'image/jpeg':
case 'jpg':
case 'jpeg':
return FileIconJpegIcon;
case 'image/png':
case 'png':
return FileIconPngIcon;
case 'image/webp':
case 'webp':
return FileIconWebpIcon;
case 'image/tiff':
case 'tiff':
return FileIconTiffIcon;
case 'image/gif':
case 'gif':
return FileIconGifIcon;
case 'image/svg':
case 'svg':
return FileIconSvgIcon;
case 'image/eps':
case 'eps':
return FileIconEpsIcon;
case 'application/pdf':
case 'pdf':
return FileIconPdfIcon;
case 'application/msword':
case 'application/x-iwork-pages-sffpages':
case 'doc':
return FileIconDocIcon;
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
case 'docx':
return FileIconDocxIcon;
case 'text/plain':
case 'txt':
return FileIconTxtIcon;
case 'csv':
return FileIconCsvIcon;
case 'application/vnd.ms-excel':
case 'xls':
return FileIconXlsIcon;
case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
case 'application/x-iwork-numbers-sffnumbers':
case 'xlsx':
return FileIconXlsxIcon;
case 'application/vnd.ms-powerpoint':
case 'application/x-iwork-keynote-sffkeynote':
case 'ppt':
return FileIconPptIcon;
case 'application/vnd.openxmlformats-officedocument.presentationml.presentation':
case 'pptx':
return FileIconPptxIcon;
case 'application/illustrator':
case 'fig':
return FileIconFigIcon;
case 'application/postscript':
case 'ai':
return FileIconAiIcon;
case 'application/vnd.adobe.photoshop':
case 'psd':
return FileIconPsdIcon;
case 'application/vnd.adobe.indesign':
case 'indd':
return FileIconInddIcon;
case 'application/vnd.adobe.afterfx':
case 'aep':
return FileIconAepIcon;
case 'audio/mpeg':
case 'audio/mp3':
case 'mp3':
return FileIconMp3Icon;
case 'audio/wav':
case 'wav':
return FileIconWavIcon;
case 'video/mpeg':
case 'mpeg':
return FileIconMpegIcon;
case 'video/mp4':
case 'mp4':
return FileIconMp4Icon;
case 'video/avi':
case 'avi':
return FileIconAviIcon;
case 'video/mkv':
case 'mkv':
return FileIconMkvIcon;
case 'text/html':
case 'html':
return FileIconHtmlIcon;
case 'text/css':
case 'css':
return FileIconCssIcon;
case 'application/rss+xml':
case 'rss':
return FileIconRssIcon;
case 'application/sql':
case 'sql':
return FileIconSqlIcon;
case 'application/javascript':
case 'js':
return FileIconJsIcon;
case 'application/json':
case 'json':
return FileIconJsonIcon;
case 'application/java':
case 'java':
return FileIconJavaIcon;
case 'application/xml':
case 'xml':
return FileIconXmlIcon;
case 'application/x-msdos-program':
case 'exe':
return FileIconExeIcon;
case 'application/x-apple-diskimage':
case 'dmg':
return FileIconDmgIcon;
case 'application/zip':
case 'zip':
return FileIconZipIcon;
case 'application/x-rar-compressed':
case 'rar':
return FileIconRarIcon;
default:
return FileIconNoneIcon;
}
}

View File

@@ -1,5 +1,6 @@
export * from './ai.js';
export * from './file-icons.js';
export * from './file-icons-rc';
export * from './import-export.js';
export * from './list.js';
export * from './loading.js';

View File

@@ -5,7 +5,7 @@ import { settingRow } from './share.css';
export type SettingRowProps = PropsWithChildren<{
name: ReactNode;
desc: ReactNode;
desc?: ReactNode;
style?: CSSProperties;
onClick?: () => void;
spreadCol?: boolean;
@@ -41,7 +41,7 @@ export const SettingRow = ({
>
<div className="left-col">
<div className="name">{name}</div>
<div className="desc">{desc}</div>
{desc && <div className="desc">{desc}</div>}
</div>
{spreadCol ? <div className="right-col">{children}</div> : children}
</div>

View File

@@ -7,6 +7,7 @@ interface SettingWrapperProps {
id?: string;
title?: ReactNode;
disabled?: boolean;
testId?: string;
}
export const SettingWrapper = ({
@@ -14,9 +15,14 @@ export const SettingWrapper = ({
title,
children,
disabled,
testId,
}: PropsWithChildren<SettingWrapperProps>) => {
return (
<div id={id} className={clsx(wrapper, disabled && wrapperDisabled)}>
<div
id={id}
className={clsx(wrapper, disabled && wrapperDisabled)}
data-testid={testId}
>
{title ? <div className="title">{title}</div> : null}
{children}
</div>

View File

@@ -20,17 +20,8 @@ import { ChatAbortIcon, ChatSendIcon } from '../../_common/icons';
import { type AIError, AIProvider } from '../../provider';
import { reportResponse } from '../../utils/action-reporter';
import { readBlobAsURL } from '../../utils/image';
import type {
ChatChip,
DocDisplayConfig,
FileChip,
} from '../ai-chat-chips/type';
import {
isCollectionChip,
isDocChip,
isFileChip,
isTagChip,
} from '../ai-chat-chips/utils';
import type { ChatChip, DocDisplayConfig } from '../ai-chat-chips/type';
import { isDocChip } from '../ai-chat-chips/utils';
import type { ChatMessage } from '../ai-chat-messages';
import { MAX_IMAGE_COUNT } from './const';
import type {
@@ -588,7 +579,6 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
const sessionId = await this.createSessionId();
let contexts = await this._getMatchedContexts(userInput);
contexts = this._filterContexts(contexts);
if (abortController.signal.aborted) {
return;
}
@@ -673,9 +663,7 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
private async _getMatchedContexts(userInput: string) {
const contextId = await this.getContextId();
if (!contextId) {
return { files: [], docs: [] };
}
const workspaceId = this.host.store.workspace.id;
const docContexts = new Map<
string,
@@ -687,7 +675,11 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
>();
const { files: matchedFiles = [], docs: matchedDocs = [] } =
(await AIProvider.context?.matchContext(userInput, contextId)) ?? {};
(await AIProvider.context?.matchContext(
userInput,
contextId,
workspaceId
)) ?? {};
matchedDocs.forEach(doc => {
docContexts.set(doc.docId, {
@@ -701,18 +693,13 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
if (context) {
context.fileContent += `\n${file.content}`;
} else {
const fileChip = this.chips.find(
chip => isFileChip(chip) && chip.fileId === file.fileId
) as FileChip | undefined;
if (fileChip && fileChip.blobId) {
fileContexts.set(file.fileId, {
blobId: fileChip.blobId,
fileName: fileChip.file.name,
fileType: fileChip.file.type,
blobId: file.blobId,
fileName: file.name,
fileType: file.mimeType,
fileContent: file.content,
});
}
}
});
this.chips.forEach(chip => {
@@ -753,42 +740,6 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
files: Array.from(fileContexts.values()),
};
}
// TODO: remove this function after workspace embedding is ready
private _filterContexts(contexts: {
docs: BlockSuitePresets.AIDocContextOption[];
files: BlockSuitePresets.AIFileContextOption[];
}) {
const docIds = this.chips.reduce((acc, chip) => {
if (isDocChip(chip)) {
acc.push(chip.docId);
}
if (isTagChip(chip)) {
const docIds = this.docDisplayConfig.getTagPageIds(chip.tagId);
acc.push(...docIds);
}
if (isCollectionChip(chip)) {
const docIds = this.docDisplayConfig.getCollectionPageIds(
chip.collectionId
);
acc.push(...docIds);
}
return acc;
}, [] as string[]);
const fileIds = this.chips.reduce((acc, chip) => {
if (isFileChip(chip) && chip.blobId) {
acc.push(chip.blobId);
}
return acc;
}, [] as string[]);
const { docs, files } = contexts;
return {
docs: docs.filter(doc => docIds.includes(doc.docId)),
files: files.filter(file => fileIds.includes(file.blobId)),
};
}
}
declare global {

View File

@@ -44,9 +44,10 @@ export const SelectorLayout = ({
);
return (
<div className={styles.root}>
<div className={styles.root} data-testid="doc-selector-layout">
<header className={styles.header}>
<RowInput
data-testid="doc-selector-search-input"
className={styles.search}
placeholder={searchPlaceholder}
onChange={onSearchChange}
@@ -73,10 +74,15 @@ export const SelectorLayout = ({
<div className={styles.footerAction}>
{actions ?? (
<>
<Button onClick={onCancel} className={styles.actionButton}>
<Button
data-testid="doc-selector-cancel-button"
onClick={onCancel}
className={styles.actionButton}
>
{t['Cancel']()}
</Button>
<Button
data-testid="doc-selector-confirm-button"
onClick={onConfirm}
className={styles.actionButton}
variant="primary"

View File

@@ -2,6 +2,7 @@ import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-in
import { ServerService } from '@affine/core/modules/cloud';
import type { SettingTab } from '@affine/core/modules/dialogs/constant';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { IndexerEmbeddingSettings } from '@affine/core/modules/workspace-indexer-embedding';
import { ServerDeploymentType } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import {
@@ -53,6 +54,8 @@ export const WorkspaceSetting = ({
return <WorkspaceSettingLicense onCloseSetting={onCloseSetting} />;
case 'workspace:integrations':
return <IntegrationSetting />;
case 'workspace:indexer-embedding':
return <IndexerEmbeddingSettings />;
default:
return null;
}
@@ -106,6 +109,12 @@ export const useWorkspaceSettingList = (): SettingSidebarItem[] => {
icon: <SaveIcon />,
testId: 'workspace-setting:storage',
},
{
key: 'workspace:indexer-embedding',
title: t['Indexer & Embedding'](),
icon: <SettingsIcon />,
testId: 'workspace-setting:indexer-embedding',
},
showBilling && {
key: 'workspace:billing' as SettingTab,
title: t['com.affine.settings.workspace.billing'](),

View File

@@ -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 }) => {

View File

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

View File

@@ -0,0 +1 @@
export const COUNT_PER_PAGE = 10;

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
import { Service } from '@toeverything/infra';
import { Embedding } from '../entities/embedding';
export class EmbeddingService extends Service {
embedding = this.framework.createEntity(Embedding);
}

View File

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

View File

@@ -0,0 +1,12 @@
export interface AttachmentFile {
fileId: string;
fileName: string;
mimeType: string;
size: number;
createdAt: string;
}
export interface IgnoredDoc {
docId: string;
createdAt: string;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',
});

View File

@@ -1,26 +1,26 @@
{
"ar": 96,
"ar": 95,
"ca": 4,
"da": 4,
"de": 96,
"el-GR": 96,
"de": 95,
"el-GR": 95,
"en": 100,
"es-AR": 96,
"es-AR": 95,
"es-CL": 97,
"es": 96,
"fa": 96,
"fr": 96,
"es": 95,
"fa": 95,
"fr": 95,
"hi": 2,
"it-IT": 96,
"it-IT": 95,
"it": 1,
"ja": 96,
"ja": 95,
"ko": 55,
"pl": 96,
"pt-BR": 96,
"ru": 96,
"sv-SE": 96,
"uk": 96,
"pl": 95,
"pt-BR": 95,
"ru": 95,
"sv-SE": 95,
"uk": 95,
"ur": 2,
"zh-Hans": 96,
"zh-Hant": 96
"zh-Hans": 95,
"zh-Hant": 95
}

View File

@@ -6187,6 +6187,58 @@ export function useAFFiNEI18N(): {
date: string;
time: string;
}>): string;
/**
* `Indexer & Embedding`
*/
["com.affine.settings.workspace.indexer-embedding.title"](): string;
/**
* `Manage AFFiNE indexing and AFFiNE AI Embedding for local content processing`
*/
["com.affine.settings.workspace.indexer-embedding.description"](): string;
/**
* `Embedding`
*/
["com.affine.settings.workspace.indexer-embedding.embedding.title"](): string;
/**
* `Embedding allows AI to retrieve your content. If the indexer uses local settings, it may affect some of the results of the Embedding.`
*/
["com.affine.settings.workspace.indexer-embedding.embedding.description"](): string;
/**
* `Select doc`
*/
["com.affine.settings.workspace.indexer-embedding.embedding.select-doc"](): string;
/**
* `Workspace Embedding`
*/
["com.affine.settings.workspace.indexer-embedding.embedding.switch.title"](): string;
/**
* `AI can call files embedded in the workspace.`
*/
["com.affine.settings.workspace.indexer-embedding.embedding.switch.description"](): string;
/**
* `Ignore Docs`
*/
["com.affine.settings.workspace.indexer-embedding.embedding.ignore-docs.title"](): string;
/**
* `The Ignored docs will not be embedded into the current workspace.`
*/
["com.affine.settings.workspace.indexer-embedding.embedding.ignore-docs.description"](): string;
/**
* `Additional attachments`
*/
["com.affine.settings.workspace.indexer-embedding.embedding.additional-attachments.title"](): string;
/**
* `The uploaded file will be embedded in the current workspace.`
*/
["com.affine.settings.workspace.indexer-embedding.embedding.additional-attachments.description"](): string;
/**
* `Remove the attachment from embedding?`
*/
["com.affine.settings.workspace.indexer-embedding.embedding.additional-attachments.remove-attachment.title"](): string;
/**
* `Attachment will be removed. AI will not continue to extract content from this attachment.`
*/
["com.affine.settings.workspace.indexer-embedding.embedding.additional-attachments.remove-attachment.description"](): string;
/**
* `Sharing doc requires AFFiNE Cloud.`
*/

View File

@@ -1546,6 +1546,19 @@
"com.affine.settings.workspace.backup.import": "Enable local workspace",
"com.affine.settings.workspace.backup.import.success.action": "Open",
"com.affine.settings.workspace.backup.delete-at": "Deleted on {{date}} at {{time}}",
"com.affine.settings.workspace.indexer-embedding.title": "Indexer & Embedding",
"com.affine.settings.workspace.indexer-embedding.description": "Manage AFFiNE indexing and AFFiNE AI Embedding for local content processing",
"com.affine.settings.workspace.indexer-embedding.embedding.title": "Embedding",
"com.affine.settings.workspace.indexer-embedding.embedding.description": "Embedding allows AI to retrieve your content. If the indexer uses local settings, it may affect some of the results of the Embedding.",
"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.description": "AI can call files embedded in the workspace.",
"com.affine.settings.workspace.indexer-embedding.embedding.ignore-docs.title": "Ignore Docs",
"com.affine.settings.workspace.indexer-embedding.embedding.ignore-docs.description": "The Ignored docs will not be embedded into the current workspace.",
"com.affine.settings.workspace.indexer-embedding.embedding.additional-attachments.title": "Additional attachments",
"com.affine.settings.workspace.indexer-embedding.embedding.additional-attachments.description": "The uploaded file will be embedded in the current workspace.",
"com.affine.settings.workspace.indexer-embedding.embedding.additional-attachments.remove-attachment.title": "Remove the attachment from embedding?",
"com.affine.settings.workspace.indexer-embedding.embedding.additional-attachments.remove-attachment.description": "Attachment will be removed. AI will not continue to extract content from this attachment.",
"com.affine.share-menu.EnableCloudDescription": "Sharing doc requires AFFiNE Cloud.",
"com.affine.share-menu.ShareMode": "Share mode",
"com.affine.share-menu.SharePage": "Share doc",

View File

@@ -4,7 +4,7 @@ import { test } from '../base/base-test';
test.describe.configure({ mode: 'serial' });
test.describe.skip('AISettings/Embedding', () => {
test.describe('AISettings/Embedding', () => {
test.beforeEach(async ({ loggedInPage: page, utils }) => {
await utils.testUtils.setupTestEnvironment(page);
await utils.chatPanel.openChatPanel(page);