mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 08:38:34 +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:
1
.github/workflows/build-test.yml
vendored
1
.github/workflows/build-test.yml
vendored
@@ -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
|
||||
|
||||
173
blocksuite/affine/components/src/icons/file-icons-rc.ts
Normal file
173
blocksuite/affine/components/src/icons/file-icons-rc.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'](),
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.`
|
||||
*/
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user