feat(core): no-access & local for workspace embedding (#12598)

## TL;DR

Workspace embedding settings opt:

* **local workspace**: show enable cloud panel
* **no-access workspace**: disable settings panel

![截屏2025-05-28 14.59.36.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/MyktQ6Qwc7H6TiRCFoYN/58e1f511-9bde-487e-a4cd-f0818c582fcb.png)

![截屏2025-05-28 15.00.19.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/MyktQ6Qwc7H6TiRCFoYN/25c3db30-bf31-4c92-a771-55ce871c9a7a.png)

> CLOSE AI-155

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Embedding settings UI now displays a tooltip indicating that only workspace owners can enable Workspace Embedding.
  - Embedding settings are modularized for local and cloud workspaces, with clear separation and appropriate enablement controls.
  - Attachments in embedding settings cannot be deleted when the settings are disabled.

- **Accessibility**
  - Settings wrapper now includes an aria-disabled attribute for improved assistive technology support.

- **Localization**
  - Added a new tooltip message: "Only the workspace owner can enable Workspace Embedding."

- **Tests**
  - Added end-to-end tests for local workspace UI and disabled state when not the workspace owner.

- **UI Improvements**
  - Updated settings panel to better reflect disabled states with tooltips and conditional controls.
  - Improved synchronization when opening the embedding settings panel for a smoother user experience.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
yoyoyohamapi
2025-05-29 04:33:07 +00:00
parent 32a29657e4
commit 66db63c845
7 changed files with 207 additions and 95 deletions
@@ -22,6 +22,7 @@ export const SettingWrapper = ({
id={id}
className={clsx(wrapper, disabled && wrapperDisabled)}
data-testid={testId}
aria-disabled={disabled}
>
{title ? <div className="title">{title}</div> : null}
{children}
@@ -33,11 +33,13 @@ interface AttachmentsProps {
totalCount: number;
onPageChange: (offset: number) => void;
onDelete: (id: string) => void;
disabled: boolean;
}
interface AttachmentItemProps {
attachment: AttachmentFile;
onDelete: (id: string) => void;
disabled: boolean;
}
const UploadingItem: React.FC<{ attachment: UploadingAttachmentFile }> = ({
@@ -88,6 +90,7 @@ const PersistedItem: React.FC<{ attachment: PersistedAttachmentFile }> = ({
const AttachmentItem: React.FC<AttachmentItemProps> = ({
attachment,
onDelete,
disabled,
}) => {
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
@@ -131,20 +134,22 @@ const AttachmentItem: React.FC<AttachmentItemProps> = ({
) : isPersistedAttachment(attachment) ? (
<PersistedItem attachment={attachment} />
) : null}
<div className={attachmentOperation}>
<Tooltip
content={t[
'com.affine.settings.workspace.indexer-embedding.embedding.additional-attachments.remove-attachment.tooltip'
]()}
>
<CloseIcon
data-testid="workspace-embedding-setting-attachment-delete-button"
onClick={handleDelete}
color={cssVarV2('icon/primary')}
style={{ cursor: 'pointer' }}
/>
</Tooltip>
</div>
{!disabled && (
<div className={attachmentOperation}>
<Tooltip
content={t[
'com.affine.settings.workspace.indexer-embedding.embedding.additional-attachments.remove-attachment.tooltip'
]()}
>
<CloseIcon
data-testid="workspace-embedding-setting-attachment-delete-button"
onClick={handleDelete}
color={cssVarV2('icon/primary')}
style={{ cursor: 'pointer' }}
/>
</Tooltip>
</div>
)}
</div>
);
};
@@ -154,6 +159,7 @@ export const Attachments: React.FC<AttachmentsProps> = ({
totalCount,
onDelete,
onPageChange,
disabled,
}) => {
const handlePageChange = useCallback(
(offset: number) => {
@@ -172,6 +178,7 @@ export const Attachments: React.FC<AttachmentsProps> = ({
key={getAttachmentId(attachment)}
attachment={attachment}
onDelete={onDelete}
disabled={disabled}
/>
))}
<Pagination
@@ -1,18 +1,22 @@
import { Button, notify, Switch } from '@affine/component';
import { Button, notify, Switch, Tooltip } from '@affine/component';
import {
SettingHeader,
SettingRow,
SettingWrapper,
} from '@affine/component/setting-components';
import { Upload } from '@affine/core/components/pure/file-upload';
import { EnableCloudPanel } from '@affine/core/desktop/dialogs/setting/workspace-setting/preference/enable-cloud';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { UserFriendlyError } from '@affine/error';
import { ServerFeature } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import { useLiveData, useService } from '@toeverything/infra';
import type React from 'react';
import { useCallback, useEffect } from 'react';
import { ServerService } from '../../cloud/services/server';
import { WorkspaceService } from '../../workspace/services/workspace';
import { COUNT_PER_PAGE } from '../constants';
import { EmbeddingService } from '../services/embedding';
import { Attachments } from './attachments';
@@ -21,7 +25,11 @@ import { IgnoredDocs } from './ignored-docs';
interface EmbeddingSettingsProps {}
export const EmbeddingSettings: React.FC<EmbeddingSettingsProps> = () => {
const EmbeddingLocal: React.FC<{}> = () => {
return <EnableCloudPanel />;
};
const EmbeddingCloud: React.FC<{ disabled: boolean }> = ({ disabled }) => {
const t = useI18n();
const embeddingService = useService(EmbeddingService);
const workspaceDialogService = useService(WorkspaceDialogService);
@@ -171,96 +179,135 @@ export const EmbeddingSettings: React.FC<EmbeddingSettingsProps> = () => {
]);
return (
<>
<SettingHeader
title={t[
'com.affine.settings.workspace.indexer-embedding.embedding.title'
<SettingWrapper
title={''}
testId="workspace-embedding-setting-wrapper"
disabled={disabled}
>
<SettingRow
name={t[
'com.affine.settings.workspace.indexer-embedding.embedding.switch.title'
]()}
subtitle={t[
'com.affine.settings.workspace.indexer-embedding.embedding.description'
desc={t[
'com.affine.settings.workspace.indexer-embedding.embedding.switch.description'
]()}
/>
<SettingWrapper title={''} testId="workspace-embedding-setting-wrapper">
<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 ?? false}
onChange={handleEmbeddingToggle}
disabled={isEnabledLoading}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.workspace.indexer-embedding.embedding.progress.title'
]()}
style={{ marginBottom: '0px' }}
>
<Switch
data-testid="workspace-embedding-setting-switch"
checked={embeddingEnabled ?? false}
onChange={handleEmbeddingToggle}
disabled={isEnabledLoading}
/>
<EmbeddingProgress status={embeddingProgress} />
<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[
'com.affine.settings.workspace.indexer-embedding.embedding.upload-file'
]()}
</Button>
</Upload>
</SettingRow>
{attachments.length > 0 && (
<Attachments
attachments={attachments}
onDelete={handleAttachmentsDelete}
totalCount={totalCount}
onPageChange={handleAttachmentsPageChange}
</SettingRow>
{
<>
<SettingRow
name={t[
'com.affine.settings.workspace.indexer-embedding.embedding.progress.title'
]()}
style={{ marginBottom: '0px' }}
/>
)}
<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'
]()}
>
<EmbeddingProgress status={embeddingProgress} />
</>
}
<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-ignore-docs-button"
data-testid="workspace-embedding-setting-upload-button"
variant="primary"
onClick={handleSelectDoc}
>
{t[
'com.affine.settings.workspace.indexer-embedding.embedding.select-doc'
'com.affine.settings.workspace.indexer-embedding.embedding.upload-file'
]()}
</Button>
</SettingRow>
</Upload>
</SettingRow>
{ignoredDocs.length > 0 && (
<IgnoredDocs
ignoredDocs={ignoredDocs}
isLoading={isIgnoredDocsLoading}
/>
)}
</SettingWrapper>
{attachments.length > 0 && (
<Attachments
disabled={disabled}
attachments={attachments}
onDelete={handleAttachmentsDelete}
totalCount={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>
{ignoredDocs.length > 0 && (
<IgnoredDocs
ignoredDocs={ignoredDocs}
isLoading={isIgnoredDocsLoading}
/>
)}
</SettingWrapper>
);
};
export const EmbeddingSettings: React.FC<EmbeddingSettingsProps> = () => {
const workspaceService = useService(WorkspaceService);
const serverService = useService(ServerService);
const isLocal = workspaceService.workspace.flavour === 'local';
const serverConfig = useLiveData(serverService.server.config$);
const isEmbeddingEnabled = serverConfig?.features.includes(
ServerFeature.CopilotEmbedding
);
const t = useI18n();
return (
<>
<Tooltip
content={
!isEmbeddingEnabled &&
t[
'com.affine.settings.workspace.indexer-embedding.embedding.disabled-tooltip'
]()
}
>
<SettingHeader
data-testid="workspace-embedding-setting-header"
title={t[
'com.affine.settings.workspace.indexer-embedding.embedding.title'
]()}
subtitle={t[
'com.affine.settings.workspace.indexer-embedding.embedding.description'
]()}
/>
</Tooltip>
{isLocal ? (
<EmbeddingLocal />
) : (
<EmbeddingCloud disabled={!isEmbeddingEnabled} />
)}
</>
);
};
+4
View File
@@ -6285,6 +6285,10 @@ export function useAFFiNEI18N(): {
* `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;
/**
* `Only the workspace owner can enable Workspace Embedding.`
*/
["com.affine.settings.workspace.indexer-embedding.embedding.disabled-tooltip"](): string;
/**
* `Select doc`
*/
@@ -1575,6 +1575,7 @@
"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.disabled-tooltip": "Only the workspace owner can enable Workspace Embedding.",
"com.affine.settings.workspace.indexer-embedding.embedding.select-doc": "Select doc",
"com.affine.settings.workspace.indexer-embedding.embedding.upload-file": "Upload file",
"com.affine.settings.workspace.indexer-embedding.embedding.switch.title": "Workspace Embedding",
@@ -1,3 +1,4 @@
import { createLocalWorkspace } from '@affine-test/kit/utils/workspace';
import { expect } from '@playwright/test';
import { test } from '../base/base-test';
@@ -43,6 +44,57 @@ test.describe('AISettings/Embedding', () => {
await utils.settings.waitForWorkspaceEmbeddingSwitchToBe(page, true);
});
test('should show enable cloud panel if workspace is local', async ({
loggedInPage: page,
utils,
}) => {
await utils.settings.closeSettingsPanel(page);
await createLocalWorkspace({ name: 'test' }, page);
await utils.settings.openSettingsPanel(page);
await expect(
page.getByTestId('publish-enable-affine-cloud-button')
).toBeVisible();
});
test('should disable embedding settings if the user is not workspace owner', async ({
loggedInPage: page,
utils,
}) => {
// mock the features to be empty(without CopilotEmbedding)
await page.route('**/graphql', async (route, request) => {
const postData = request.postData();
if (postData && postData.includes('serverConfig')) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: {
serverConfig: {
version: '1.0.0',
baseUrl: 'http://localhost:8080',
name: 'AFFiNE',
features: [],
type: 'cloud',
initialized: true,
credentialsRequirement: null,
},
},
}),
});
} else {
await route.continue();
}
});
await page.reload();
await utils.settings.openSettingsPanel(page);
const wrapper = await page.getByTestId(
'workspace-embedding-setting-wrapper'
);
await expect(wrapper).toHaveAttribute('aria-disabled', 'true');
});
test('should show error message if enable workspace embedding failed', async ({
loggedInPage: page,
utils,
@@ -7,7 +7,7 @@ export class SettingsPanelUtils {
if (await page.getByTestId('workspace-setting:embedding').isHidden()) {
await page.getByTestId('slider-bar-workspace-setting-button').click();
await page.getByTestId('workspace-setting:embedding').click();
await page.getByTestId('workspace-embedding-setting-wrapper').waitFor({
await page.getByTestId('workspace-embedding-setting-header').waitFor({
state: 'visible',
});
}