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

View File

@@ -22,6 +22,7 @@ export const SettingWrapper = ({
id={id} id={id}
className={clsx(wrapper, disabled && wrapperDisabled)} className={clsx(wrapper, disabled && wrapperDisabled)}
data-testid={testId} data-testid={testId}
aria-disabled={disabled}
> >
{title ? <div className="title">{title}</div> : null} {title ? <div className="title">{title}</div> : null}
{children} {children}

View File

@@ -33,11 +33,13 @@ interface AttachmentsProps {
totalCount: number; totalCount: number;
onPageChange: (offset: number) => void; onPageChange: (offset: number) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
disabled: boolean;
} }
interface AttachmentItemProps { interface AttachmentItemProps {
attachment: AttachmentFile; attachment: AttachmentFile;
onDelete: (id: string) => void; onDelete: (id: string) => void;
disabled: boolean;
} }
const UploadingItem: React.FC<{ attachment: UploadingAttachmentFile }> = ({ const UploadingItem: React.FC<{ attachment: UploadingAttachmentFile }> = ({
@@ -88,6 +90,7 @@ const PersistedItem: React.FC<{ attachment: PersistedAttachmentFile }> = ({
const AttachmentItem: React.FC<AttachmentItemProps> = ({ const AttachmentItem: React.FC<AttachmentItemProps> = ({
attachment, attachment,
onDelete, onDelete,
disabled,
}) => { }) => {
const t = useI18n(); const t = useI18n();
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
@@ -131,20 +134,22 @@ const AttachmentItem: React.FC<AttachmentItemProps> = ({
) : isPersistedAttachment(attachment) ? ( ) : isPersistedAttachment(attachment) ? (
<PersistedItem attachment={attachment} /> <PersistedItem attachment={attachment} />
) : null} ) : null}
<div className={attachmentOperation}> {!disabled && (
<Tooltip <div className={attachmentOperation}>
content={t[ <Tooltip
'com.affine.settings.workspace.indexer-embedding.embedding.additional-attachments.remove-attachment.tooltip' content={t[
]()} 'com.affine.settings.workspace.indexer-embedding.embedding.additional-attachments.remove-attachment.tooltip'
> ]()}
<CloseIcon >
data-testid="workspace-embedding-setting-attachment-delete-button" <CloseIcon
onClick={handleDelete} data-testid="workspace-embedding-setting-attachment-delete-button"
color={cssVarV2('icon/primary')} onClick={handleDelete}
style={{ cursor: 'pointer' }} color={cssVarV2('icon/primary')}
/> style={{ cursor: 'pointer' }}
</Tooltip> />
</div> </Tooltip>
</div>
)}
</div> </div>
); );
}; };
@@ -154,6 +159,7 @@ export const Attachments: React.FC<AttachmentsProps> = ({
totalCount, totalCount,
onDelete, onDelete,
onPageChange, onPageChange,
disabled,
}) => { }) => {
const handlePageChange = useCallback( const handlePageChange = useCallback(
(offset: number) => { (offset: number) => {
@@ -172,6 +178,7 @@ export const Attachments: React.FC<AttachmentsProps> = ({
key={getAttachmentId(attachment)} key={getAttachmentId(attachment)}
attachment={attachment} attachment={attachment}
onDelete={onDelete} onDelete={onDelete}
disabled={disabled}
/> />
))} ))}
<Pagination <Pagination

View File

@@ -1,18 +1,22 @@
import { Button, notify, Switch } from '@affine/component'; import { Button, notify, Switch, Tooltip } from '@affine/component';
import { import {
SettingHeader, SettingHeader,
SettingRow, SettingRow,
SettingWrapper, SettingWrapper,
} from '@affine/component/setting-components'; } from '@affine/component/setting-components';
import { Upload } from '@affine/core/components/pure/file-upload'; import { Upload } from '@affine/core/components/pure/file-upload';
import { EnableCloudPanel } from '@affine/core/desktop/dialogs/setting/workspace-setting/preference/enable-cloud';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs'; import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { UserFriendlyError } from '@affine/error'; import { UserFriendlyError } from '@affine/error';
import { ServerFeature } from '@affine/graphql';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import track from '@affine/track'; import track from '@affine/track';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import type React from 'react'; import type React from 'react';
import { useCallback, useEffect } 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 { COUNT_PER_PAGE } from '../constants';
import { EmbeddingService } from '../services/embedding'; import { EmbeddingService } from '../services/embedding';
import { Attachments } from './attachments'; import { Attachments } from './attachments';
@@ -21,7 +25,11 @@ import { IgnoredDocs } from './ignored-docs';
interface EmbeddingSettingsProps {} 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 t = useI18n();
const embeddingService = useService(EmbeddingService); const embeddingService = useService(EmbeddingService);
const workspaceDialogService = useService(WorkspaceDialogService); const workspaceDialogService = useService(WorkspaceDialogService);
@@ -171,96 +179,135 @@ export const EmbeddingSettings: React.FC<EmbeddingSettingsProps> = () => {
]); ]);
return ( return (
<> <SettingWrapper
<SettingHeader title={''}
title={t[ testId="workspace-embedding-setting-wrapper"
'com.affine.settings.workspace.indexer-embedding.embedding.title' disabled={disabled}
>
<SettingRow
name={t[
'com.affine.settings.workspace.indexer-embedding.embedding.switch.title'
]()} ]()}
subtitle={t[ desc={t[
'com.affine.settings.workspace.indexer-embedding.embedding.description' 'com.affine.settings.workspace.indexer-embedding.embedding.switch.description'
]()} ]()}
/> >
<SettingWrapper title={''} testId="workspace-embedding-setting-wrapper"> <Switch
<SettingRow data-testid="workspace-embedding-setting-switch"
name={t[ checked={embeddingEnabled ?? false}
'com.affine.settings.workspace.indexer-embedding.embedding.switch.title' onChange={handleEmbeddingToggle}
]()} disabled={isEnabledLoading}
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' }}
/> />
</SettingRow>
<EmbeddingProgress status={embeddingProgress} /> {
<>
<SettingRow <SettingRow
name={t[ name={t[
'com.affine.settings.workspace.indexer-embedding.embedding.additional-attachments.title' 'com.affine.settings.workspace.indexer-embedding.embedding.progress.title'
]()} ]()}
desc={t[ style={{ marginBottom: '0px' }}
'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 <EmbeddingProgress status={embeddingProgress} />
name={t[ </>
'com.affine.settings.workspace.indexer-embedding.embedding.ignore-docs.title' }
]()}
desc={t[ <SettingRow
'com.affine.settings.workspace.indexer-embedding.embedding.ignore-docs.description' 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 <Button
data-testid="workspace-embedding-setting-ignore-docs-button" data-testid="workspace-embedding-setting-upload-button"
variant="primary" variant="primary"
onClick={handleSelectDoc}
> >
{t[ {t[
'com.affine.settings.workspace.indexer-embedding.embedding.select-doc' 'com.affine.settings.workspace.indexer-embedding.embedding.upload-file'
]()} ]()}
</Button> </Button>
</SettingRow> </Upload>
</SettingRow>
{ignoredDocs.length > 0 && ( {attachments.length > 0 && (
<IgnoredDocs <Attachments
ignoredDocs={ignoredDocs} disabled={disabled}
isLoading={isIgnoredDocsLoading} attachments={attachments}
/> onDelete={handleAttachmentsDelete}
)} totalCount={totalCount}
</SettingWrapper> 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} />
)}
</> </>
); );
}; };

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.` * `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; ["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` * `Select doc`
*/ */

View File

@@ -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.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.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.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.select-doc": "Select doc",
"com.affine.settings.workspace.indexer-embedding.embedding.upload-file": "Upload file", "com.affine.settings.workspace.indexer-embedding.embedding.upload-file": "Upload file",
"com.affine.settings.workspace.indexer-embedding.embedding.switch.title": "Workspace Embedding", "com.affine.settings.workspace.indexer-embedding.embedding.switch.title": "Workspace Embedding",

View File

@@ -1,3 +1,4 @@
import { createLocalWorkspace } from '@affine-test/kit/utils/workspace';
import { expect } from '@playwright/test'; import { expect } from '@playwright/test';
import { test } from '../base/base-test'; import { test } from '../base/base-test';
@@ -43,6 +44,57 @@ test.describe('AISettings/Embedding', () => {
await utils.settings.waitForWorkspaceEmbeddingSwitchToBe(page, true); 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 ({ test('should show error message if enable workspace embedding failed', async ({
loggedInPage: page, loggedInPage: page,
utils, utils,

View File

@@ -7,7 +7,7 @@ export class SettingsPanelUtils {
if (await page.getByTestId('workspace-setting:embedding').isHidden()) { if (await page.getByTestId('workspace-setting:embedding').isHidden()) {
await page.getByTestId('slider-bar-workspace-setting-button').click(); await page.getByTestId('slider-bar-workspace-setting-button').click();
await page.getByTestId('workspace-setting:embedding').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', state: 'visible',
}); });
} }