mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-29 08:09:52 +08:00
feat(server): refactor for byok (#14911)
This commit is contained in:
@@ -354,12 +354,6 @@ declare global {
|
||||
files?: ContextMatchedFileChunk[];
|
||||
docs?: ContextMatchedDocChunk[];
|
||||
}>;
|
||||
applyDocUpdates: (
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
op: string,
|
||||
updates: string
|
||||
) => Promise<string>;
|
||||
addContextBlob: (options: {
|
||||
blobId: string;
|
||||
contextId: string;
|
||||
|
||||
@@ -2,7 +2,6 @@ import track from '@affine/track';
|
||||
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
|
||||
import { type EditorHost, ShadowlessElement } from '@blocksuite/affine/std';
|
||||
import { LoadingIcon } from '@blocksuite/affine-components/icons';
|
||||
import type { NotificationService } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
CloseIcon,
|
||||
@@ -17,8 +16,6 @@ import { css, html, nothing } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import { AIProvider } from '../../provider';
|
||||
import { BlockDiffProvider } from '../../services/block-diff';
|
||||
import { diffMarkdown } from '../../utils/apply-model/markdown-diff';
|
||||
import { copyText } from '../../utils/editor-actions';
|
||||
import { AI_CHAT_AUTO_SCROLL_PAUSE_EVENT } from '../ai-chat-messages/auto-scroll';
|
||||
@@ -218,61 +215,21 @@ export class DocEditTool extends WithDisposable(ShadowlessElement) {
|
||||
@state()
|
||||
accessor isCollapsed = false;
|
||||
|
||||
@state()
|
||||
accessor applyingMap: Record<string, boolean> = {};
|
||||
|
||||
@state()
|
||||
accessor acceptingMap: Record<string, boolean> = {};
|
||||
|
||||
get blockDiffService() {
|
||||
return this.host?.std.getOptional(BlockDiffProvider);
|
||||
}
|
||||
|
||||
get isBusy() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
isBusyForOp(op: string) {
|
||||
return this.applyingMap[op] || this.acceptingMap[op];
|
||||
}
|
||||
|
||||
private async _handleApply(op: string, updates: string) {
|
||||
if (
|
||||
!this.host ||
|
||||
this.data.type !== 'tool-result' ||
|
||||
this.isBusyForOp(op)
|
||||
) {
|
||||
private _handleApply(op: string) {
|
||||
if (!this.host || this.data.type !== 'tool-result') {
|
||||
return;
|
||||
}
|
||||
this.applyingMap = { ...this.applyingMap, [op]: true };
|
||||
try {
|
||||
const markdown = await AIProvider.context?.applyDocUpdates(
|
||||
this.host.std.workspace.id,
|
||||
this.data.args.doc_id,
|
||||
op,
|
||||
updates
|
||||
);
|
||||
if (!markdown) {
|
||||
return;
|
||||
}
|
||||
track.applyModel.chat.$.apply({
|
||||
instruction: this.data.args.instructions,
|
||||
operation: op,
|
||||
});
|
||||
await this.blockDiffService?.apply(this.host.store, markdown);
|
||||
} catch (error) {
|
||||
this.notificationService.notify({
|
||||
title: 'Failed to apply updates',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
accent: 'error',
|
||||
onClose: function (): void {},
|
||||
});
|
||||
} finally {
|
||||
this.applyingMap = { ...this.applyingMap, [op]: false };
|
||||
}
|
||||
track.applyModel.chat.$.apply({
|
||||
instruction: this.data.args.instructions,
|
||||
operation: op,
|
||||
});
|
||||
}
|
||||
|
||||
private async _handleReject(op: string) {
|
||||
private _handleReject(op: string) {
|
||||
if (!this.host || this.data.type !== 'tool-result') {
|
||||
return;
|
||||
}
|
||||
@@ -281,45 +238,16 @@ export class DocEditTool extends WithDisposable(ShadowlessElement) {
|
||||
instruction: this.data.args.instructions,
|
||||
operation: op,
|
||||
});
|
||||
this.blockDiffService?.setChangedMarkdown(null);
|
||||
this.blockDiffService?.rejectAll();
|
||||
}
|
||||
|
||||
private async _handleAccept(op: string, updates: string) {
|
||||
if (
|
||||
!this.host ||
|
||||
this.data.type !== 'tool-result' ||
|
||||
this.isBusyForOp(op)
|
||||
) {
|
||||
private _handleAccept(op: string) {
|
||||
if (!this.host || this.data.type !== 'tool-result') {
|
||||
return;
|
||||
}
|
||||
this.acceptingMap = { ...this.acceptingMap, [op]: true };
|
||||
try {
|
||||
const changedMarkdown = await AIProvider.context?.applyDocUpdates(
|
||||
this.host.std.workspace.id,
|
||||
this.data.args.doc_id,
|
||||
op,
|
||||
updates
|
||||
);
|
||||
if (!changedMarkdown) {
|
||||
return;
|
||||
}
|
||||
track.applyModel.chat.$.accept({
|
||||
instruction: this.data.args.instructions,
|
||||
operation: op,
|
||||
});
|
||||
await this.blockDiffService?.apply(this.host.store, changedMarkdown);
|
||||
await this.blockDiffService?.acceptAll(this.host.store);
|
||||
} catch (error) {
|
||||
this.notificationService.notify({
|
||||
title: 'Failed to apply updates',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
accent: 'error',
|
||||
onClose: function (): void {},
|
||||
});
|
||||
} finally {
|
||||
this.acceptingMap = { ...this.acceptingMap, [op]: false };
|
||||
}
|
||||
track.applyModel.chat.$.accept({
|
||||
instruction: this.data.args.instructions,
|
||||
operation: op,
|
||||
});
|
||||
}
|
||||
|
||||
private async _toggleCollapse() {
|
||||
@@ -421,7 +349,7 @@ export class DocEditTool extends WithDisposable(ShadowlessElement) {
|
||||
return repeat(
|
||||
result.result,
|
||||
change => change.op,
|
||||
({ op, updates, originalContent, changedContent }) => {
|
||||
({ op, originalContent, changedContent }) => {
|
||||
const diffs = diffMarkdown(originalContent, changedContent);
|
||||
return html`
|
||||
<div class="doc-edit-tool-result-wrapper">
|
||||
@@ -449,14 +377,7 @@ export class DocEditTool extends WithDisposable(ShadowlessElement) {
|
||||
${CopyIcon()}
|
||||
<affine-tooltip>Copy</affine-tooltip>
|
||||
</button>
|
||||
<button
|
||||
@click=${() => this._handleApply(op, updates)}
|
||||
?disabled=${this.isBusyForOp(op)}
|
||||
>
|
||||
${this.applyingMap[op]
|
||||
? html`${LoadingIcon()} Applying`
|
||||
: 'Apply'}
|
||||
</button>
|
||||
<button @click=${() => this._handleApply(op)}>Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-edit-tool-result-card-content">
|
||||
@@ -473,18 +394,12 @@ export class DocEditTool extends WithDisposable(ShadowlessElement) {
|
||||
</button>
|
||||
<button
|
||||
class="doc-edit-tool-result-accept"
|
||||
@click=${() => this._handleAccept(op, updates)}
|
||||
?disabled=${this.isBusyForOp(op)}
|
||||
style="${this.isBusyForOp(op)
|
||||
? 'pointer-events: none; opacity: 0.6;'
|
||||
: ''}"
|
||||
@click=${() => this._handleAccept(op)}
|
||||
>
|
||||
${this.acceptingMap[op]
|
||||
? html`${LoadingIcon()}`
|
||||
: DoneIcon({
|
||||
style: `color: ${unsafeCSSVarV2('icon/activated')}`,
|
||||
})}
|
||||
${this.acceptingMap[op] ? 'Accepting...' : 'Accept'}
|
||||
${DoneIcon({
|
||||
style: `color: ${unsafeCSSVarV2('icon/activated')}`,
|
||||
})}
|
||||
Accept
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
addContextCategoryMutation,
|
||||
addContextDocMutation,
|
||||
addContextFileMutation,
|
||||
applyDocUpdatesMutation,
|
||||
cleanupCopilotSessionMutation,
|
||||
createCopilotContextMutation,
|
||||
createCopilotMessageMutation,
|
||||
@@ -473,6 +472,7 @@ export class CopilotClient {
|
||||
actionVersion,
|
||||
runId,
|
||||
retry,
|
||||
byokLeaseId,
|
||||
}: {
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
@@ -483,6 +483,7 @@ export class CopilotClient {
|
||||
actionVersion?: string;
|
||||
runId?: string;
|
||||
retry?: boolean;
|
||||
byokLeaseId?: string;
|
||||
},
|
||||
endpoint = Endpoint.StreamObject
|
||||
) {
|
||||
@@ -499,6 +500,7 @@ export class CopilotClient {
|
||||
actionVersion,
|
||||
runId,
|
||||
retry,
|
||||
byokLeaseId,
|
||||
});
|
||||
if (queryString) {
|
||||
url += `?${queryString}`;
|
||||
@@ -511,12 +513,14 @@ export class CopilotClient {
|
||||
sessionId: string,
|
||||
messageId?: string,
|
||||
seed?: string,
|
||||
endpoint = Endpoint.Images
|
||||
endpoint = Endpoint.Images,
|
||||
byokLeaseId?: string
|
||||
) {
|
||||
let url = `/api/copilot/chat/${sessionId}/${endpoint}`;
|
||||
const queryString = this.paramsToQueryString({
|
||||
messageId,
|
||||
seed,
|
||||
byokLeaseId,
|
||||
});
|
||||
if (queryString) {
|
||||
url += `?${queryString}`;
|
||||
@@ -549,23 +553,6 @@ export class CopilotClient {
|
||||
}).then(res => res.queryWorkspaceEmbeddingStatus);
|
||||
}
|
||||
|
||||
applyDocUpdates(
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
op: string,
|
||||
updates: string
|
||||
) {
|
||||
return this.gql({
|
||||
query: applyDocUpdatesMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
docId,
|
||||
op,
|
||||
updates,
|
||||
},
|
||||
}).then(res => res.applyDocUpdates);
|
||||
}
|
||||
|
||||
addContextBlob(options: OptionsField<typeof addContextBlobMutation>) {
|
||||
return this.gql({
|
||||
query: addContextBlobMutation,
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { UserFriendlyError } from '@affine/error';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { type CopilotClient, Endpoint } from './copilot-client';
|
||||
import { textToText, toImage } from './request';
|
||||
|
||||
const electronApis = vi.hoisted(() => ({
|
||||
byokStorage: undefined as
|
||||
| {
|
||||
isSupported: () => Promise<boolean>;
|
||||
getWorkspaceLeaseProviders: (workspaceId: string) => Promise<
|
||||
Array<{
|
||||
provider: string;
|
||||
name: string;
|
||||
apiKey: string;
|
||||
description?: string | null;
|
||||
endpoint?: string | null;
|
||||
sortOrder?: number | null;
|
||||
enabled?: boolean | null;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
| undefined,
|
||||
}));
|
||||
|
||||
const createWorkspaceByokLocalLeaseMutation = vi.hoisted(() =>
|
||||
Symbol('createWorkspaceByokLocalLeaseMutation')
|
||||
);
|
||||
|
||||
vi.mock('@affine/electron-api', () => ({
|
||||
apis: electronApis,
|
||||
}));
|
||||
|
||||
vi.mock('@affine/graphql', () => ({
|
||||
ByokProvider: {
|
||||
openai: 'openai',
|
||||
anthropic: 'anthropic',
|
||||
gemini: 'gemini',
|
||||
fal: 'fal',
|
||||
},
|
||||
createWorkspaceByokLocalLeaseMutation,
|
||||
}));
|
||||
|
||||
function createClient(
|
||||
overrides: Partial<
|
||||
Pick<
|
||||
CopilotClient,
|
||||
'gql' | 'createMessage' | 'chatTextStream' | 'imagesStream'
|
||||
>
|
||||
> = {}
|
||||
) {
|
||||
return {
|
||||
gql: vi.fn().mockResolvedValue({
|
||||
createWorkspaceByokLocalLease: { leaseId: 'lease-1' },
|
||||
}),
|
||||
createMessage: vi.fn().mockResolvedValue('message-1'),
|
||||
chatTextStream: vi.fn(),
|
||||
imagesStream: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as CopilotClient;
|
||||
}
|
||||
|
||||
async function drain(stream: AsyncIterable<unknown>) {
|
||||
for await (const chunk of stream) {
|
||||
void chunk;
|
||||
}
|
||||
}
|
||||
|
||||
describe('AI request BYOK local lease handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('BUILD_CONFIG', { isElectron: true });
|
||||
electronApis.byokStorage = {
|
||||
isSupported: vi.fn().mockResolvedValue(true),
|
||||
getWorkspaceLeaseProviders: vi.fn().mockResolvedValue([
|
||||
{
|
||||
provider: 'openai',
|
||||
name: 'OpenAI',
|
||||
apiKey: 'sk-local',
|
||||
},
|
||||
]),
|
||||
};
|
||||
});
|
||||
|
||||
test('fails closed when local BYOK providers exist but lease creation fails', async () => {
|
||||
const client = createClient({
|
||||
gql: vi.fn().mockRejectedValue(new Error('mutation failed')),
|
||||
});
|
||||
|
||||
const result = textToText({
|
||||
client,
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'workspace-1',
|
||||
content: 'hello',
|
||||
}) as Promise<string>;
|
||||
|
||||
await expect(result).rejects.toThrow('mutation failed');
|
||||
await expect(result).rejects.toBeInstanceOf(UserFriendlyError);
|
||||
expect(client.chatTextStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('wraps local BYOK storage support failures as user friendly errors', async () => {
|
||||
electronApis.byokStorage = {
|
||||
isSupported: vi.fn().mockRejectedValue(new Error('support check failed')),
|
||||
getWorkspaceLeaseProviders: vi.fn(),
|
||||
};
|
||||
const client = createClient();
|
||||
|
||||
const result = textToText({
|
||||
client,
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'workspace-1',
|
||||
content: 'hello',
|
||||
}) as Promise<string>;
|
||||
|
||||
await expect(result).rejects.toThrow('support check failed');
|
||||
await expect(result).rejects.toBeInstanceOf(UserFriendlyError);
|
||||
expect(client.chatTextStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('wraps local BYOK provider loading failures as user friendly errors', async () => {
|
||||
electronApis.byokStorage = {
|
||||
isSupported: vi.fn().mockResolvedValue(true),
|
||||
getWorkspaceLeaseProviders: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('provider load failed')),
|
||||
};
|
||||
const client = createClient();
|
||||
|
||||
const result = textToText({
|
||||
client,
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'workspace-1',
|
||||
content: 'hello',
|
||||
}) as Promise<string>;
|
||||
|
||||
await expect(result).rejects.toThrow('provider load failed');
|
||||
await expect(result).rejects.toBeInstanceOf(UserFriendlyError);
|
||||
expect(client.chatTextStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not create local BYOK lease after cancellation', async () => {
|
||||
const controller = new AbortController();
|
||||
const client = createClient({
|
||||
createMessage: vi.fn().mockImplementation(async () => {
|
||||
controller.abort();
|
||||
return 'message-1';
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
textToText({
|
||||
client,
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'workspace-1',
|
||||
content: 'hello',
|
||||
signal: controller.signal,
|
||||
}) as Promise<string>
|
||||
).resolves.toBe('');
|
||||
expect(client.gql).not.toHaveBeenCalled();
|
||||
expect(client.chatTextStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not create stream local BYOK lease after cancellation', async () => {
|
||||
const controller = new AbortController();
|
||||
const client = createClient({
|
||||
createMessage: vi.fn().mockImplementation(async () => {
|
||||
controller.abort();
|
||||
return 'message-1';
|
||||
}),
|
||||
});
|
||||
|
||||
await drain(
|
||||
textToText({
|
||||
client,
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'workspace-1',
|
||||
content: 'hello',
|
||||
stream: true,
|
||||
signal: controller.signal,
|
||||
}) as AsyncIterable<string>
|
||||
);
|
||||
|
||||
expect(client.gql).not.toHaveBeenCalled();
|
||||
expect(client.chatTextStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not create text stream when cancelled while creating local BYOK lease', async () => {
|
||||
const controller = new AbortController();
|
||||
const client = createClient({
|
||||
gql: vi.fn().mockImplementation(async () => {
|
||||
controller.abort();
|
||||
return { createWorkspaceByokLocalLease: { leaseId: 'lease-1' } };
|
||||
}),
|
||||
});
|
||||
|
||||
await drain(
|
||||
textToText({
|
||||
client,
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'workspace-1',
|
||||
content: 'hello',
|
||||
stream: true,
|
||||
signal: controller.signal,
|
||||
}) as AsyncIterable<string>
|
||||
);
|
||||
|
||||
expect(client.gql).toHaveBeenCalled();
|
||||
expect(client.chatTextStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not create text request when cancelled while creating local BYOK lease', async () => {
|
||||
const controller = new AbortController();
|
||||
const client = createClient({
|
||||
gql: vi.fn().mockImplementation(async () => {
|
||||
controller.abort();
|
||||
return { createWorkspaceByokLocalLease: { leaseId: 'lease-1' } };
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
textToText({
|
||||
client,
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'workspace-1',
|
||||
content: 'hello',
|
||||
signal: controller.signal,
|
||||
}) as Promise<string>
|
||||
).resolves.toBe('');
|
||||
|
||||
expect(client.gql).toHaveBeenCalled();
|
||||
expect(client.chatTextStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not create image local BYOK lease after cancellation', async () => {
|
||||
const controller = new AbortController();
|
||||
const client = createClient({
|
||||
createMessage: vi.fn().mockImplementation(async () => {
|
||||
controller.abort();
|
||||
return 'message-1';
|
||||
}),
|
||||
});
|
||||
|
||||
await drain(
|
||||
toImage({
|
||||
client,
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'workspace-1',
|
||||
content: 'image',
|
||||
endpoint: Endpoint.Images,
|
||||
signal: controller.signal,
|
||||
}) as AsyncIterable<string>
|
||||
);
|
||||
|
||||
expect(client.gql).not.toHaveBeenCalled();
|
||||
expect(client.imagesStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not create image stream when cancelled while creating local BYOK lease', async () => {
|
||||
const controller = new AbortController();
|
||||
const client = createClient({
|
||||
gql: vi.fn().mockImplementation(async () => {
|
||||
controller.abort();
|
||||
return { createWorkspaceByokLocalLease: { leaseId: 'lease-1' } };
|
||||
}),
|
||||
});
|
||||
|
||||
await drain(
|
||||
toImage({
|
||||
client,
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'workspace-1',
|
||||
content: 'image',
|
||||
endpoint: Endpoint.Images,
|
||||
signal: controller.signal,
|
||||
}) as AsyncIterable<string>
|
||||
);
|
||||
|
||||
expect(client.gql).toHaveBeenCalled();
|
||||
expect(client.imagesStream).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { AIToolsConfig } from '@affine/core/modules/ai-button';
|
||||
import { apis, type ClientHandler } from '@affine/electron-api';
|
||||
import { UserFriendlyError } from '@affine/error';
|
||||
import {
|
||||
ByokProvider,
|
||||
createWorkspaceByokLocalLeaseMutation,
|
||||
} from '@affine/graphql';
|
||||
import { partition } from 'lodash-es';
|
||||
|
||||
import { AIProvider } from './ai-provider';
|
||||
@@ -7,9 +13,99 @@ import { toTextStream } from './event-source';
|
||||
|
||||
const TIMEOUT = 50000;
|
||||
|
||||
function isElectronBuild() {
|
||||
return typeof BUILD_CONFIG !== 'undefined' && BUILD_CONFIG.isElectron;
|
||||
}
|
||||
|
||||
function byokStorageApi(): ClientHandler['byokStorage'] | undefined {
|
||||
return isElectronBuild() ? apis?.byokStorage : undefined;
|
||||
}
|
||||
|
||||
function toGraphqlByokProvider(provider: string): ByokProvider | null {
|
||||
switch (provider) {
|
||||
case ByokProvider.openai:
|
||||
return ByokProvider.openai;
|
||||
case ByokProvider.anthropic:
|
||||
return ByokProvider.anthropic;
|
||||
case ByokProvider.gemini:
|
||||
return ByokProvider.gemini;
|
||||
case ByokProvider.fal:
|
||||
return ByokProvider.fal;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function errorMetadata(error: unknown) {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return { kind: typeof error };
|
||||
}
|
||||
const record = error as Record<string, unknown>;
|
||||
return {
|
||||
name: typeof record.name === 'string' ? record.name : undefined,
|
||||
code: typeof record.code === 'string' ? record.code : undefined,
|
||||
status:
|
||||
typeof record.status === 'number' || typeof record.status === 'string'
|
||||
? record.status
|
||||
: undefined,
|
||||
type: typeof record.type === 'string' ? record.type : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function createWorkspaceByokLocalLease(
|
||||
client: CopilotClient,
|
||||
workspaceId?: string
|
||||
) {
|
||||
const storage = byokStorageApi();
|
||||
if (!workspaceId || !storage) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!(await storage.isSupported())) return undefined;
|
||||
const providers = await storage.getWorkspaceLeaseProviders(workspaceId);
|
||||
if (!providers.length) return undefined;
|
||||
const leaseProviders = providers.flatMap(provider => {
|
||||
const gqlProvider = toGraphqlByokProvider(provider.provider);
|
||||
return gqlProvider
|
||||
? [
|
||||
{
|
||||
provider: gqlProvider,
|
||||
name: provider.name,
|
||||
description: provider.description ?? null,
|
||||
apiKey: provider.apiKey,
|
||||
endpoint: provider.endpoint ?? null,
|
||||
sortOrder: provider.sortOrder ?? 0,
|
||||
enabled: provider.enabled ?? true,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
if (!leaseProviders.length) return undefined;
|
||||
|
||||
const result = await client.gql({
|
||||
query: createWorkspaceByokLocalLeaseMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId,
|
||||
providers: leaseProviders,
|
||||
},
|
||||
},
|
||||
});
|
||||
return result.createWorkspaceByokLocalLease.leaseId;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Failed to create workspace BYOK local lease',
|
||||
errorMetadata(error)
|
||||
);
|
||||
throw UserFriendlyError.fromAny(error);
|
||||
}
|
||||
}
|
||||
|
||||
export type TextToTextOptions = {
|
||||
client: CopilotClient;
|
||||
sessionId: string;
|
||||
workspaceId?: string;
|
||||
content?: string;
|
||||
attachments?: (string | Blob | File)[];
|
||||
params?: Record<string, any>;
|
||||
@@ -114,6 +210,7 @@ async function createMessage({
|
||||
export function textToText({
|
||||
client,
|
||||
sessionId,
|
||||
workspaceId,
|
||||
content,
|
||||
attachments,
|
||||
params,
|
||||
@@ -145,6 +242,16 @@ export function textToText({
|
||||
signal,
|
||||
});
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
const byokLeaseId = await createWorkspaceByokLocalLease(
|
||||
client,
|
||||
workspaceId
|
||||
);
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
const eventSource = client.chatTextStream(
|
||||
{
|
||||
sessionId,
|
||||
@@ -156,6 +263,7 @@ export function textToText({
|
||||
actionVersion,
|
||||
runId,
|
||||
retry,
|
||||
byokLeaseId,
|
||||
},
|
||||
endpoint
|
||||
);
|
||||
@@ -203,6 +311,16 @@ export function textToText({
|
||||
signal,
|
||||
});
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return '';
|
||||
}
|
||||
const byokLeaseId = await createWorkspaceByokLocalLease(
|
||||
client,
|
||||
workspaceId
|
||||
);
|
||||
if (signal?.aborted) {
|
||||
return '';
|
||||
}
|
||||
const eventSource = client.chatTextStream(
|
||||
{
|
||||
sessionId,
|
||||
@@ -214,6 +332,7 @@ export function textToText({
|
||||
actionVersion,
|
||||
runId,
|
||||
retry,
|
||||
byokLeaseId,
|
||||
},
|
||||
endpoint
|
||||
);
|
||||
@@ -258,6 +377,7 @@ export function textToText({
|
||||
export function toImage({
|
||||
content,
|
||||
sessionId,
|
||||
workspaceId,
|
||||
attachments,
|
||||
params,
|
||||
seed,
|
||||
@@ -284,6 +404,16 @@ export function toImage({
|
||||
signal,
|
||||
});
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
const byokLeaseId = await createWorkspaceByokLocalLease(
|
||||
client,
|
||||
workspaceId
|
||||
);
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
const eventSource =
|
||||
endpoint === Endpoint.Action
|
||||
? client.chatTextStream(
|
||||
@@ -294,10 +424,17 @@ export function toImage({
|
||||
actionVersion,
|
||||
runId,
|
||||
retry,
|
||||
byokLeaseId,
|
||||
},
|
||||
Endpoint.Action
|
||||
)
|
||||
: client.imagesStream(sessionId, messageId, seed, endpoint);
|
||||
: client.imagesStream(
|
||||
sessionId,
|
||||
messageId,
|
||||
seed,
|
||||
endpoint,
|
||||
byokLeaseId
|
||||
);
|
||||
AIProvider.LAST_ACTION_SESSIONID = sessionId;
|
||||
|
||||
for await (const event of toTextStream(eventSource, {
|
||||
|
||||
@@ -722,14 +722,6 @@ Could you make a new website based on these notes and send back just the html fi
|
||||
threshold
|
||||
);
|
||||
},
|
||||
applyDocUpdates: async (
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
op: string,
|
||||
updates: string
|
||||
) => {
|
||||
return client.applyDocUpdates(workspaceId, docId, op, updates);
|
||||
},
|
||||
addContextBlob: async (options: { blobId: string; contextId: string }) => {
|
||||
return client.addContextBlob({
|
||||
contextId: options.contextId,
|
||||
|
||||
+346
@@ -0,0 +1,346 @@
|
||||
import { Button, Modal, notify } from '@affine/component';
|
||||
import {
|
||||
ByokKeyStorage,
|
||||
ByokProvider,
|
||||
testWorkspaceByokConfigMutation as testByokMutation,
|
||||
upsertWorkspaceByokConfigMutation as upsertByokMutation,
|
||||
} from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { logByokError } from './errors';
|
||||
import * as styles from './index.css';
|
||||
import { readLocalKeys, upsertLocalKey } from './local-storage';
|
||||
import { byokT, providerLabels, storageLabel } from './metadata';
|
||||
import type {
|
||||
ByokKey,
|
||||
ByokSettings,
|
||||
ByokStorage,
|
||||
ByokTestResult,
|
||||
GqlFn,
|
||||
} from './types';
|
||||
|
||||
export const AddKeyModal = ({
|
||||
workspaceId,
|
||||
settings,
|
||||
editingKey,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
localKeys,
|
||||
setLocalKeys,
|
||||
localStorageSupported,
|
||||
canAddServerKey,
|
||||
canAddLocalKey,
|
||||
gql,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
settings: ByokSettings;
|
||||
editingKey: ByokKey | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSaved: () => Promise<void>;
|
||||
localKeys: ByokKey[];
|
||||
setLocalKeys: (keys: ByokKey[]) => void;
|
||||
localStorageSupported: boolean;
|
||||
canAddServerKey: boolean;
|
||||
canAddLocalKey: boolean;
|
||||
gql?: GqlFn;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const [provider, setProvider] = useState<ByokProvider>(ByokProvider.openai);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [storage, setStorage] = useState<ByokStorage>(ByokKeyStorage.server);
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [endpoint, setEndpoint] = useState('');
|
||||
const [testResult, setTestResult] = useState<ByokTestResult | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const canTestStoredConfig =
|
||||
storage === ByokKeyStorage.server &&
|
||||
editingKey?.storage === ByokKeyStorage.server &&
|
||||
editingKey.provider === provider;
|
||||
const canTest = !!apiKey || canTestStoredConfig;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
setProvider(editingKey?.provider ?? ByokProvider.openai);
|
||||
setName(editingKey?.name ?? '');
|
||||
setDescription(editingKey?.description ?? '');
|
||||
setStorage(
|
||||
editingKey?.storage ??
|
||||
(canAddServerKey ? ByokKeyStorage.server : ByokKeyStorage.local)
|
||||
);
|
||||
setApiKey('');
|
||||
setEndpoint(editingKey?.endpoint ?? '');
|
||||
setTestResult(null);
|
||||
}, [canAddServerKey, editingKey, open]);
|
||||
|
||||
const testKey = useCallback(async () => {
|
||||
if (!gql) {
|
||||
return;
|
||||
}
|
||||
setTesting(true);
|
||||
try {
|
||||
const result = await gql({
|
||||
query: testByokMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId,
|
||||
provider,
|
||||
storage,
|
||||
apiKey: apiKey || null,
|
||||
endpoint: endpoint || null,
|
||||
configId: canTestStoredConfig ? editingKey.id : null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const nextResult = result.testWorkspaceByokConfig as
|
||||
| ByokTestResult
|
||||
| undefined;
|
||||
setTestResult(nextResult ?? null);
|
||||
if (nextResult && !nextResult.ok) {
|
||||
notify.error({
|
||||
title: byokT(t, 'notify.test-failed.title'),
|
||||
message: nextResult.message,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}, [
|
||||
apiKey,
|
||||
canTestStoredConfig,
|
||||
editingKey,
|
||||
endpoint,
|
||||
gql,
|
||||
provider,
|
||||
storage,
|
||||
t,
|
||||
workspaceId,
|
||||
]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!testResult?.ok || !gql) {
|
||||
return;
|
||||
}
|
||||
if (storage === ByokKeyStorage.local) {
|
||||
const saved = await upsertLocalKey(workspaceId, {
|
||||
id:
|
||||
editingKey?.storage === ByokKeyStorage.local
|
||||
? editingKey.id
|
||||
: crypto.randomUUID(),
|
||||
provider,
|
||||
name,
|
||||
description,
|
||||
apiKey,
|
||||
endpoint: endpoint || null,
|
||||
sortOrder:
|
||||
editingKey?.storage === ByokKeyStorage.local
|
||||
? editingKey.sortOrder
|
||||
: localKeys.length,
|
||||
enabled: true,
|
||||
});
|
||||
if (!saved) {
|
||||
notify.error({
|
||||
title: byokT(t, 'notify.local-save-failed.title'),
|
||||
message: byokT(t, 'notify.local-save-failed.message'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setLocalKeys(await readLocalKeys(workspaceId));
|
||||
} else {
|
||||
await gql({
|
||||
query: upsertByokMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId,
|
||||
id:
|
||||
editingKey?.storage === ByokKeyStorage.server
|
||||
? editingKey.id
|
||||
: null,
|
||||
provider,
|
||||
name,
|
||||
description,
|
||||
storage,
|
||||
apiKey: apiKey || null,
|
||||
endpoint: endpoint || null,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
await onSaved();
|
||||
}
|
||||
onOpenChange(false);
|
||||
setApiKey('');
|
||||
setTestResult(null);
|
||||
}, [
|
||||
apiKey,
|
||||
description,
|
||||
editingKey,
|
||||
endpoint,
|
||||
gql,
|
||||
localKeys,
|
||||
name,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
provider,
|
||||
setLocalKeys,
|
||||
storage,
|
||||
t,
|
||||
testResult?.ok,
|
||||
workspaceId,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
width={520}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
editingKey ? byokT(t, 'modal.edit-title') : byokT(t, 'modal.add-title')
|
||||
}
|
||||
description={byokT(t, 'modal.description')}
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>{byokT(t, 'field.provider')}</span>
|
||||
<select
|
||||
className={styles.input}
|
||||
value={provider}
|
||||
onChange={event => {
|
||||
setProvider(event.target.value as ByokProvider);
|
||||
setTestResult(null);
|
||||
}}
|
||||
>
|
||||
{settings.allowedProviders.map(provider => (
|
||||
<option key={provider} value={provider}>
|
||||
{providerLabels[provider]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>{byokT(t, 'field.key-name')}</span>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={name}
|
||||
onChange={event => setName(event.target.value)}
|
||||
placeholder={byokT(t, 'placeholder.key-name')}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>{byokT(t, 'field.description')}</span>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={description}
|
||||
onChange={event => setDescription(event.target.value)}
|
||||
placeholder={byokT(t, 'placeholder.description')}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>{byokT(t, 'field.storage')}</span>
|
||||
<select
|
||||
className={styles.input}
|
||||
value={storage}
|
||||
disabled={!!editingKey}
|
||||
onChange={event => {
|
||||
setStorage(event.target.value as ByokStorage);
|
||||
setTestResult(null);
|
||||
}}
|
||||
>
|
||||
<option value={ByokKeyStorage.server} disabled={!canAddServerKey}>
|
||||
{storageLabel(t, ByokKeyStorage.server)}
|
||||
</option>
|
||||
<option
|
||||
value={ByokKeyStorage.local}
|
||||
disabled={!localStorageSupported || !canAddLocalKey}
|
||||
>
|
||||
{canAddLocalKey
|
||||
? byokT(t, 'storage.local-this-device')
|
||||
: byokT(t, 'storage.local-desktop-only')}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>{byokT(t, 'field.api-key')}</span>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={apiKey}
|
||||
onChange={event => {
|
||||
setApiKey(event.target.value);
|
||||
setTestResult(null);
|
||||
}}
|
||||
type="password"
|
||||
/>
|
||||
</label>
|
||||
{settings.customEndpointSupported ? (
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>{byokT(t, 'field.endpoint')}</span>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={endpoint}
|
||||
onChange={event => {
|
||||
setEndpoint(event.target.value);
|
||||
setTestResult(null);
|
||||
}}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<div className={styles.modalActions}>
|
||||
<span
|
||||
className={`${styles.testStatus} ${
|
||||
testResult?.ok
|
||||
? styles.success
|
||||
: testResult && !testResult.ok
|
||||
? styles.error
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{testResult?.ok
|
||||
? byokT(t, 'status.key-verified')
|
||||
: testResult
|
||||
? byokT(t, 'status.key-test-failed')
|
||||
: ''}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!canTest || testing}
|
||||
onClick={() => {
|
||||
testKey().catch(error => {
|
||||
logByokError('Failed to test BYOK key', error);
|
||||
notify.error({
|
||||
title: byokT(t, 'notify.test-failed.title'),
|
||||
message: byokT(t, 'notify.operation-failed.message'),
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{byokT(t, 'action.test-key')}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
{byokT(t, 'action.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!testResult?.ok || !name}
|
||||
onClick={() => {
|
||||
save().catch(error => {
|
||||
logByokError('Failed to save BYOK key', error);
|
||||
notify.error({
|
||||
title: byokT(t, 'notify.save-failed.title'),
|
||||
message: byokT(t, 'notify.operation-failed.message'),
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{byokT(t, 'action.save-key')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import {
|
||||
ChatWithAiIcon,
|
||||
ImageIcon,
|
||||
PenIcon,
|
||||
TocIcon,
|
||||
TranscriptWithAiIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import * as styles from './index.css';
|
||||
import { byokT, capabilityRows, warningDescription } from './metadata';
|
||||
import type { ByokKey, ByokSettings } from './types';
|
||||
|
||||
function coverageIcon(
|
||||
icon: (typeof capabilityRows)[number]['icon']
|
||||
): ReactNode {
|
||||
switch (icon) {
|
||||
case 'chat':
|
||||
return <ChatWithAiIcon className={styles.capabilityIconSvg} />;
|
||||
case 'action':
|
||||
return <PenIcon className={styles.capabilityIconSvg} />;
|
||||
case 'image':
|
||||
return <ImageIcon className={styles.capabilityIconSvg} />;
|
||||
case 'transcript':
|
||||
return <TranscriptWithAiIcon className={styles.capabilityIconSvg} />;
|
||||
case 'indexing':
|
||||
return <TocIcon className={styles.capabilityIconSvg} />;
|
||||
}
|
||||
}
|
||||
|
||||
function isRowCovered(row: (typeof capabilityRows)[number], keys: ByokKey[]) {
|
||||
if (!row.coverageCapabilities.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return keys.some(key => {
|
||||
if (!key.configured || !key.enabled) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(!('storage' in row) || row.storage === key.storage) &&
|
||||
row.coverageCapabilities.every(capability =>
|
||||
key.capabilities.includes(capability)
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export const CoveragePanel = ({
|
||||
keys,
|
||||
settings,
|
||||
}: {
|
||||
keys: ByokKey[];
|
||||
settings: ByokSettings;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div className={styles.title}>{byokT(t, 'coverage.title')}</div>
|
||||
</div>
|
||||
<div className={styles.rows}>
|
||||
{capabilityRows.map(row => {
|
||||
const warning = settings.warnings.find(
|
||||
w => w.featureKind === row.featureKind
|
||||
);
|
||||
const covered = isRowCovered(row, keys);
|
||||
return (
|
||||
<div
|
||||
className={`${styles.row} ${styles.capabilityRow} ${
|
||||
covered ? '' : styles.capabilityRowInactive
|
||||
}`}
|
||||
data-covered={covered}
|
||||
data-testid={`workspace-byok-coverage-${row.featureKind}`}
|
||||
key={row.featureKind}
|
||||
>
|
||||
<div
|
||||
className={`${styles.capabilityIcon} ${
|
||||
covered ? styles.capabilityIconActive : ''
|
||||
}`}
|
||||
>
|
||||
{coverageIcon(row.icon)}
|
||||
</div>
|
||||
<div className={styles.rowMain}>
|
||||
<div className={styles.rowTitle}>{byokT(t, row.titleKey)}</div>
|
||||
<div className={styles.rowDescription}>
|
||||
{warningDescription(t, warning) ?? byokT(t, row.fallbackKey)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
function errorMetadata(error: unknown) {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return { kind: typeof error };
|
||||
}
|
||||
const record = error as Record<string, unknown>;
|
||||
return {
|
||||
name: typeof record.name === 'string' ? record.name : undefined,
|
||||
code: typeof record.code === 'string' ? record.code : undefined,
|
||||
status:
|
||||
typeof record.status === 'number' || typeof record.status === 'string'
|
||||
? record.status
|
||||
: undefined,
|
||||
type: typeof record.type === 'string' ? record.type : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function logByokError(context: string, error: unknown) {
|
||||
console.warn(context, errorMetadata(error));
|
||||
}
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const stack = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 24,
|
||||
});
|
||||
|
||||
export const panel = style({
|
||||
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
background: cssVarV2('layer/background/primary'),
|
||||
});
|
||||
|
||||
export const panelHeader = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
padding: '12px 16px',
|
||||
borderBottom: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
});
|
||||
|
||||
export const title = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 600,
|
||||
color: cssVarV2('text/primary'),
|
||||
});
|
||||
|
||||
export const description = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
lineHeight: '20px',
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
|
||||
export const empty = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '28px 20px',
|
||||
textAlign: 'center',
|
||||
});
|
||||
|
||||
export const rows = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
export const row = style({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '24px 1fr auto',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '12px 16px',
|
||||
borderBottom: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
selectors: {
|
||||
'&:last-child': {
|
||||
borderBottom: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const capabilityRow = style({
|
||||
gridTemplateColumns: '32px 1fr',
|
||||
});
|
||||
|
||||
export const capabilityRowInactive = style({
|
||||
opacity: 0.48,
|
||||
background: cssVarV2('layer/background/secondary'),
|
||||
});
|
||||
|
||||
export const capabilityIcon = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 6,
|
||||
color: cssVarV2('text/secondary'),
|
||||
background: cssVarV2('layer/background/secondary'),
|
||||
});
|
||||
|
||||
export const capabilityIconActive = style({
|
||||
color: cssVarV2('button/primary'),
|
||||
background: '#f0f7ff',
|
||||
});
|
||||
|
||||
export const capabilityIconSvg = style({
|
||||
width: 16,
|
||||
height: 16,
|
||||
});
|
||||
|
||||
export const rowDisabled = style({
|
||||
opacity: 0.55,
|
||||
background: cssVarV2('layer/background/secondary'),
|
||||
});
|
||||
|
||||
export const dragHandle = style({
|
||||
color: cssVarV2('text/secondary'),
|
||||
cursor: 'grab',
|
||||
textAlign: 'center',
|
||||
});
|
||||
|
||||
export const rowMain = style({
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
});
|
||||
|
||||
export const rowTitle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
minWidth: 0,
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 600,
|
||||
color: cssVarV2('text/primary'),
|
||||
});
|
||||
|
||||
export const rowDescription = style({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
|
||||
export const tags = style({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 6,
|
||||
});
|
||||
|
||||
export const tag = style({
|
||||
borderRadius: 999,
|
||||
padding: '2px 8px',
|
||||
fontSize: 11,
|
||||
lineHeight: '16px',
|
||||
color: cssVarV2('text/secondary'),
|
||||
background: cssVarV2('layer/background/secondary'),
|
||||
});
|
||||
|
||||
export const dangerTag = style({
|
||||
color: '#b42318',
|
||||
background: '#fff5f5',
|
||||
});
|
||||
|
||||
export const rowActions = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
});
|
||||
|
||||
export const notice = style({
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
background: cssVarV2('layer/background/secondary'),
|
||||
});
|
||||
|
||||
export const locked = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
padding: 24,
|
||||
borderRadius: 8,
|
||||
background: cssVarV2('layer/background/secondary'),
|
||||
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
});
|
||||
|
||||
export const form = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
});
|
||||
|
||||
export const field = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
height: '3em',
|
||||
});
|
||||
|
||||
export const label = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
|
||||
export const input = style({
|
||||
height: 32,
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
padding: '0 10px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
lineHeight: '22px',
|
||||
background: cssVarV2('layer/background/primary'),
|
||||
color: cssVarV2('text/primary'),
|
||||
outline: 'none',
|
||||
selectors: {
|
||||
'&::placeholder': {
|
||||
color: cssVarV2('text/placeholder'),
|
||||
},
|
||||
'&:focus': {
|
||||
borderColor: cssVarV2('button/primary'),
|
||||
boxShadow: '0px 0px 0px 2px rgba(30, 150, 235, 0.30)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const modalActions = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
});
|
||||
|
||||
export const testStatus = style({
|
||||
marginRight: 'auto',
|
||||
fontSize: cssVar('fontXs'),
|
||||
});
|
||||
|
||||
export const error = style({
|
||||
color: '#b42318',
|
||||
});
|
||||
|
||||
export const success = style({
|
||||
color: '#168a58',
|
||||
});
|
||||
|
||||
export const chart = style({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(30, minmax(4px, 1fr))',
|
||||
alignItems: 'end',
|
||||
gap: 4,
|
||||
height: 140,
|
||||
padding: '16px 16px 24px',
|
||||
});
|
||||
|
||||
export const bar = style({
|
||||
minHeight: 2,
|
||||
borderRadius: '4px 4px 0 0',
|
||||
background: '#5b8def',
|
||||
});
|
||||
+700
@@ -0,0 +1,700 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import type * as Infra from '@toeverything/infra';
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const gqlMock = vi.hoisted(() => vi.fn());
|
||||
const workspaceState = vi.hoisted(() => ({
|
||||
id: 'workspace-1',
|
||||
}));
|
||||
const electronApiState = vi.hoisted(() => ({
|
||||
apis: undefined as
|
||||
| {
|
||||
byokStorage?: {
|
||||
isSupported: () => Promise<boolean>;
|
||||
listWorkspaceKeys: (workspaceId: string) => Promise<unknown[]>;
|
||||
};
|
||||
}
|
||||
| undefined,
|
||||
}));
|
||||
const WorkspaceServerServiceToken = vi.hoisted(
|
||||
() => class WorkspaceServerService {}
|
||||
);
|
||||
const WorkspaceServiceToken = vi.hoisted(() => class WorkspaceService {});
|
||||
|
||||
const ByokProvider = vi.hoisted(() => ({
|
||||
openai: 'openai',
|
||||
anthropic: 'anthropic',
|
||||
gemini: 'gemini',
|
||||
fal: 'fal',
|
||||
}));
|
||||
const ByokKeyStorage = vi.hoisted(() => ({
|
||||
server: 'server',
|
||||
local: 'local',
|
||||
}));
|
||||
const ByokKeyTestStatus = vi.hoisted(() => ({
|
||||
untested: 'untested',
|
||||
passed: 'passed',
|
||||
failed: 'failed',
|
||||
}));
|
||||
|
||||
const workspaceByokSettingsQuery = vi.hoisted(() =>
|
||||
Symbol('workspaceByokSettingsQuery')
|
||||
);
|
||||
const testWorkspaceByokConfigMutation = vi.hoisted(() =>
|
||||
Symbol('testWorkspaceByokConfigMutation')
|
||||
);
|
||||
const upsertWorkspaceByokConfigMutation = vi.hoisted(() =>
|
||||
Symbol('upsertWorkspaceByokConfigMutation')
|
||||
);
|
||||
const clearWorkspaceByokConfigsMutation = vi.hoisted(() =>
|
||||
Symbol('clearWorkspaceByokConfigsMutation')
|
||||
);
|
||||
const deleteWorkspaceByokConfigMutation = vi.hoisted(() =>
|
||||
Symbol('deleteWorkspaceByokConfigMutation')
|
||||
);
|
||||
|
||||
vi.mock('@affine/component', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & { children: ReactNode }) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
DragHandle: () => <span>drag-handle</span>,
|
||||
IconButton: ({ title, onClick }: { title: string; onClick?: () => void }) => (
|
||||
<button onClick={onClick}>{title}</button>
|
||||
),
|
||||
Modal: ({
|
||||
open,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
notify: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@affine/component/setting-components', () => ({
|
||||
SettingHeader: ({
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}) => (
|
||||
<header>
|
||||
<h1>{title}</h1>
|
||||
{subtitle ? <p>{subtitle}</p> : null}
|
||||
</header>
|
||||
),
|
||||
SettingWrapper: ({ children }: { children: ReactNode }) => (
|
||||
<main>{children}</main>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@affine/core/modules/cloud', () => ({
|
||||
WorkspaceServerService: WorkspaceServerServiceToken,
|
||||
}));
|
||||
|
||||
vi.mock('@affine/core/modules/workspace', () => ({
|
||||
WorkspaceService: WorkspaceServiceToken,
|
||||
}));
|
||||
|
||||
vi.mock('@affine/electron-api', () => ({
|
||||
get apis() {
|
||||
return electronApiState.apis;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@affine/graphql', () => ({
|
||||
ByokKeyStorage,
|
||||
ByokKeyTestStatus,
|
||||
ByokProvider,
|
||||
clearWorkspaceByokConfigsMutation,
|
||||
deleteWorkspaceByokConfigMutation,
|
||||
testWorkspaceByokConfigMutation,
|
||||
upsertWorkspaceByokConfigMutation,
|
||||
workspaceByokSettingsQuery,
|
||||
}));
|
||||
|
||||
vi.mock('@affine/i18n', () => {
|
||||
const messages: Record<string, string> = {
|
||||
'com.affine.settings.workspace.byok.action.add-key': 'Add key',
|
||||
'com.affine.settings.workspace.byok.action.edit': 'Edit',
|
||||
'com.affine.settings.workspace.byok.action.delete': 'Delete',
|
||||
'com.affine.settings.workspace.byok.action.test-key': 'Test key',
|
||||
'com.affine.settings.workspace.byok.action.save-key': 'Save key',
|
||||
'com.affine.settings.workspace.byok.action.cancel': 'Cancel',
|
||||
'com.affine.settings.workspace.byok.action.clear-all':
|
||||
'Clear all BYOK keys',
|
||||
'com.affine.settings.workspace.byok.field.api-key': 'API key',
|
||||
'com.affine.settings.workspace.byok.field.storage': 'Key storage',
|
||||
'com.affine.settings.workspace.byok.placeholder.key-name': 'Primary',
|
||||
'com.affine.settings.workspace.byok.status.key-verified': 'Key verified',
|
||||
'com.affine.settings.workspace.byok.status.disabled-after-failure':
|
||||
'Disabled after failure',
|
||||
'com.affine.settings.workspace.byok.storage.local': 'Local',
|
||||
'com.affine.settings.workspace.byok.storage.server': 'Server',
|
||||
'com.affine.settings.workspace.byok.storage.local-this-device':
|
||||
'Local (this device)',
|
||||
'com.affine.settings.workspace.byok.storage.local-desktop-only':
|
||||
'Local (Desktop only)',
|
||||
'com.affine.settings.workspace.byok.usage.tokens': '{{count}} tokens',
|
||||
'com.affine.settings.workspace.byok.notify.operation-failed.message':
|
||||
'Please try again.',
|
||||
'com.affine.settings.workspace.byok.notify.test-failed.title':
|
||||
'Key test failed',
|
||||
'com.affine.settings.workspace.byok.notify.load-failed.title':
|
||||
'BYOK settings not loaded',
|
||||
'com.affine.settings.workspace.byok.notify.save-failed.title':
|
||||
'BYOK key not saved',
|
||||
'com.affine.settings.workspace.byok.notify.delete-failed.title':
|
||||
'BYOK key not deleted',
|
||||
'com.affine.settings.workspace.byok.notify.reorder-failed.title':
|
||||
'BYOK keys not reordered',
|
||||
'com.affine.settings.workspace.byok.notify.clear-failed.title':
|
||||
'BYOK keys not cleared',
|
||||
};
|
||||
const translate = (key: string, options?: Record<string, unknown>) => {
|
||||
let message = messages[key] ?? key;
|
||||
for (const [name, value] of Object.entries(options ?? {})) {
|
||||
message = message.replaceAll(`{{${name}}}`, String(value));
|
||||
}
|
||||
return message;
|
||||
};
|
||||
const t = new Proxy(
|
||||
{
|
||||
t: translate,
|
||||
},
|
||||
{
|
||||
get(target, key: string) {
|
||||
if (key in target) {
|
||||
return target[key as keyof typeof target];
|
||||
}
|
||||
return (options?: Record<string, unknown>) => translate(key, options);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
useI18n: () => t,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@blocksuite/icons/rc', () => ({
|
||||
ChatWithAiIcon: () => <span>chat-ai</span>,
|
||||
DeleteIcon: () => <span>delete</span>,
|
||||
EditIcon: () => <span>edit</span>,
|
||||
ImageIcon: () => <span>image</span>,
|
||||
PenIcon: () => <span>pen</span>,
|
||||
TocIcon: () => <span>toc</span>,
|
||||
TranscriptWithAiIcon: () => <span>transcript</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@toeverything/infra', async importOriginal => {
|
||||
const actual = await importOriginal<typeof Infra>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useService: (token: unknown) => {
|
||||
if (token === WorkspaceServerServiceToken) {
|
||||
return {
|
||||
server: {
|
||||
gql: gqlMock,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (token === WorkspaceServiceToken) {
|
||||
return {
|
||||
workspace: workspaceState,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { WorkspaceByokSetting } from '.';
|
||||
import { logByokError } from './errors';
|
||||
import { UsagePanel } from './usage';
|
||||
|
||||
function settings(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
workspaceId: 'workspace-1',
|
||||
entitled: true,
|
||||
serverEntitled: true,
|
||||
localEntitled: false,
|
||||
entitlementRequired: ['Pro', 'Team', 'Believer'],
|
||||
allowedProviders: ['openai', 'anthropic', 'gemini', 'fal'],
|
||||
localStorageSupported: false,
|
||||
customEndpointSupported: false,
|
||||
hasAiPlan: true,
|
||||
keys: [],
|
||||
warnings: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function byokKey(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'server-key',
|
||||
provider: ByokProvider.openai,
|
||||
name: 'Primary',
|
||||
description: 'Workspace fallback key',
|
||||
storage: ByokKeyStorage.server,
|
||||
configured: true,
|
||||
enabled: true,
|
||||
endpoint: null,
|
||||
endpointEditable: false,
|
||||
sortOrder: 0,
|
||||
capabilities: ['Text', 'Image input', 'Actions', 'Image generate'],
|
||||
testStatus: ByokKeyTestStatus.passed,
|
||||
disabledReason: null,
|
||||
lastTestedAt: null,
|
||||
lastTestError: null,
|
||||
lastUsedAt: null,
|
||||
lastErrorAt: null,
|
||||
lastError: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function settingsResponse(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
workspace: {
|
||||
byokSettings: settings(overrides),
|
||||
byokUsage: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('WorkspaceByokSetting', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
gqlMock.mockReset();
|
||||
gqlMock.mockImplementation(async ({ query }) => {
|
||||
if (query === workspaceByokSettingsQuery) {
|
||||
return settingsResponse();
|
||||
}
|
||||
throw new Error('Unexpected GraphQL operation');
|
||||
});
|
||||
vi.stubGlobal('BUILD_CONFIG', { isElectron: false });
|
||||
electronApiState.apis = undefined;
|
||||
});
|
||||
|
||||
test('renders locked state without key management controls', async () => {
|
||||
gqlMock.mockImplementation(async ({ query }) => {
|
||||
if (query === workspaceByokSettingsQuery) {
|
||||
return settingsResponse({
|
||||
entitled: false,
|
||||
serverEntitled: false,
|
||||
localEntitled: false,
|
||||
});
|
||||
}
|
||||
throw new Error('Unexpected GraphQL operation');
|
||||
});
|
||||
|
||||
render(<WorkspaceByokSetting />);
|
||||
|
||||
await screen.findByTestId('workspace-byok-locked');
|
||||
expect(screen.queryByText('Add key')).toBeNull();
|
||||
expect(screen.queryByTestId('workspace-byok-empty')).toBeNull();
|
||||
});
|
||||
|
||||
test('renders empty state and keeps save disabled until key test passes', async () => {
|
||||
gqlMock.mockImplementation(async ({ query }) => {
|
||||
if (query === workspaceByokSettingsQuery) {
|
||||
return settingsResponse();
|
||||
}
|
||||
if (query === testWorkspaceByokConfigMutation) {
|
||||
return {
|
||||
testWorkspaceByokConfig: {
|
||||
ok: true,
|
||||
status: 'passed',
|
||||
message: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (query === upsertWorkspaceByokConfigMutation) {
|
||||
return { upsertWorkspaceByokConfig: { id: 'server-key' } };
|
||||
}
|
||||
throw new Error('Unexpected GraphQL operation');
|
||||
});
|
||||
|
||||
render(<WorkspaceByokSetting />);
|
||||
|
||||
await screen.findByTestId('workspace-byok-empty');
|
||||
fireEvent.click(screen.getAllByText('Add key')[0]);
|
||||
expect(screen.getByText<HTMLButtonElement>('Save key').disabled).toBe(true);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Primary'), {
|
||||
target: { value: 'Primary' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('API key'), {
|
||||
target: { value: 'sk-test' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Test key'));
|
||||
|
||||
await screen.findByText('Key verified');
|
||||
expect(screen.getByText<HTMLButtonElement>('Save key').disabled).toBe(
|
||||
false
|
||||
);
|
||||
fireEvent.click(screen.getByText('Save key'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(gqlMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: upsertWorkspaceByokConfigMutation,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps local storage disabled on web even for local-entitled users', async () => {
|
||||
gqlMock.mockImplementation(async ({ query }) => {
|
||||
if (query === workspaceByokSettingsQuery) {
|
||||
return settingsResponse({
|
||||
localEntitled: true,
|
||||
localStorageSupported: true,
|
||||
});
|
||||
}
|
||||
throw new Error('Unexpected GraphQL operation');
|
||||
});
|
||||
|
||||
render(<WorkspaceByokSetting />);
|
||||
|
||||
await screen.findByTestId('workspace-byok-empty');
|
||||
fireEvent.click(screen.getAllByText('Add key')[0]);
|
||||
|
||||
const storageSelect =
|
||||
screen.getByLabelText<HTMLSelectElement>('Key storage');
|
||||
const localOption = Array.from(storageSelect.options).find(
|
||||
option => option.value === ByokKeyStorage.local
|
||||
);
|
||||
expect(localOption?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
test('reorders server keys within their storage bucket', async () => {
|
||||
gqlMock.mockImplementation(async ({ query }) => {
|
||||
if (query === workspaceByokSettingsQuery) {
|
||||
return settingsResponse({
|
||||
keys: [
|
||||
byokKey({ id: 'server-1', name: 'First', sortOrder: 0 }),
|
||||
byokKey({ id: 'server-2', name: 'Second', sortOrder: 1 }),
|
||||
],
|
||||
});
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
render(<WorkspaceByokSetting />);
|
||||
|
||||
const firstRow = (await screen.findByText('OpenAI / First')).closest(
|
||||
'[draggable="true"]'
|
||||
);
|
||||
const secondRow = screen
|
||||
.getByText('OpenAI / Second')
|
||||
.closest('[draggable="true"]');
|
||||
|
||||
expect(firstRow).not.toBeNull();
|
||||
expect(secondRow).not.toBeNull();
|
||||
fireEvent.dragStart(firstRow as Element);
|
||||
fireEvent.dragOver(secondRow as Element);
|
||||
fireEvent.drop(secondRow as Element);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(gqlMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variables: expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
workspaceId: 'workspace-1',
|
||||
storage: ByokKeyStorage.server,
|
||||
ids: ['server-2', 'server-1'],
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('marks coverage rows by configured provider support', async () => {
|
||||
let keys = [
|
||||
byokKey({ provider: ByokProvider.openai }),
|
||||
byokKey({
|
||||
id: 'disabled-gemini',
|
||||
provider: ByokProvider.gemini,
|
||||
enabled: false,
|
||||
capabilities: [
|
||||
'Text',
|
||||
'Image input',
|
||||
'Actions',
|
||||
'Image generate',
|
||||
'Transcript',
|
||||
'Indexing',
|
||||
],
|
||||
}),
|
||||
byokKey({
|
||||
id: 'local-gemini',
|
||||
provider: ByokProvider.gemini,
|
||||
storage: ByokKeyStorage.local,
|
||||
capabilities: ['Text', 'Image input', 'Actions', 'Image generate'],
|
||||
}),
|
||||
];
|
||||
|
||||
gqlMock.mockImplementation(async ({ query }) => {
|
||||
if (query === workspaceByokSettingsQuery) {
|
||||
return settingsResponse({
|
||||
keys,
|
||||
});
|
||||
}
|
||||
throw new Error('Unexpected GraphQL operation');
|
||||
});
|
||||
|
||||
render(<WorkspaceByokSetting />);
|
||||
|
||||
expect(
|
||||
(await screen.findByTestId('workspace-byok-coverage-chat')).dataset
|
||||
.covered
|
||||
).toBe('true');
|
||||
expect(
|
||||
screen.getByTestId('workspace-byok-coverage-action').dataset.covered
|
||||
).toBe('true');
|
||||
expect(
|
||||
screen.getByTestId('workspace-byok-coverage-image').dataset.covered
|
||||
).toBe('true');
|
||||
expect(
|
||||
screen.getByTestId('workspace-byok-coverage-transcript').dataset.covered
|
||||
).toBe('false');
|
||||
expect(
|
||||
screen.getByTestId('workspace-byok-coverage-workspace_indexing').dataset
|
||||
.covered
|
||||
).toBe('false');
|
||||
expect(screen.getAllByTestId(/^workspace-byok-coverage-/)).toHaveLength(5);
|
||||
|
||||
cleanup();
|
||||
keys = [
|
||||
byokKey({
|
||||
provider: ByokProvider.gemini,
|
||||
capabilities: [
|
||||
'Text',
|
||||
'Image input',
|
||||
'Actions',
|
||||
'Image generate',
|
||||
'Transcript',
|
||||
'Indexing',
|
||||
],
|
||||
}),
|
||||
];
|
||||
render(<WorkspaceByokSetting />);
|
||||
|
||||
expect(
|
||||
(await screen.findByTestId('workspace-byok-coverage-transcript')).dataset
|
||||
.covered
|
||||
).toBe('true');
|
||||
expect(
|
||||
screen.getByTestId('workspace-byok-coverage-workspace_indexing').dataset
|
||||
.covered
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
test('restores a failed server row after key test passes', async () => {
|
||||
gqlMock.mockImplementation(async ({ query }) => {
|
||||
if (query === workspaceByokSettingsQuery) {
|
||||
return settingsResponse({
|
||||
keys: [
|
||||
byokKey({
|
||||
enabled: false,
|
||||
testStatus: ByokKeyTestStatus.failed,
|
||||
disabledReason: 'recent_failure',
|
||||
lastErrorAt: '2026-05-01T00:00:00.000Z',
|
||||
lastError: 'Provider rejected the API key.',
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
if (query === testWorkspaceByokConfigMutation) {
|
||||
return {
|
||||
testWorkspaceByokConfig: {
|
||||
ok: true,
|
||||
status: 'passed',
|
||||
message: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (query === upsertWorkspaceByokConfigMutation) {
|
||||
return {
|
||||
upsertWorkspaceByokConfig: {
|
||||
id: 'server-key',
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error('Unexpected GraphQL operation');
|
||||
});
|
||||
|
||||
render(<WorkspaceByokSetting />);
|
||||
|
||||
await screen.findByText('Disabled after failure');
|
||||
fireEvent.click(screen.getByText('Edit'));
|
||||
fireEvent.change(screen.getByLabelText('API key'), {
|
||||
target: { value: 'sk-test' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Test key'));
|
||||
|
||||
await screen.findByText('Key verified');
|
||||
fireEvent.click(screen.getByText('Save key'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(gqlMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: upsertWorkspaceByokConfigMutation,
|
||||
variables: expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
id: 'server-key',
|
||||
enabled: true,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('tests a saved server key without resending plaintext', async () => {
|
||||
gqlMock.mockImplementation(async ({ query }) => {
|
||||
if (query === workspaceByokSettingsQuery) {
|
||||
return settingsResponse({
|
||||
keys: [byokKey()],
|
||||
});
|
||||
}
|
||||
if (query === testWorkspaceByokConfigMutation) {
|
||||
return {
|
||||
testWorkspaceByokConfig: {
|
||||
ok: true,
|
||||
status: 'passed',
|
||||
message: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (query === upsertWorkspaceByokConfigMutation) {
|
||||
return {
|
||||
upsertWorkspaceByokConfig: {
|
||||
id: 'server-key',
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error('Unexpected GraphQL operation');
|
||||
});
|
||||
|
||||
render(<WorkspaceByokSetting />);
|
||||
|
||||
await screen.findByText('OpenAI / Primary');
|
||||
fireEvent.click(screen.getByText('Edit'));
|
||||
expect(screen.getByText<HTMLButtonElement>('Test key').disabled).toBe(
|
||||
false
|
||||
);
|
||||
fireEvent.click(screen.getByText('Test key'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(gqlMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: testWorkspaceByokConfigMutation,
|
||||
variables: expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
apiKey: null,
|
||||
configId: 'server-key',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await screen.findByText('Key verified');
|
||||
fireEvent.click(screen.getByText('Save key'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(gqlMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: upsertWorkspaceByokConfigMutation,
|
||||
variables: expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
apiKey: null,
|
||||
id: 'server-key',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('UsagePanel', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test('aggregates usage rows by date before rendering bars', () => {
|
||||
const today = new Date().toISOString();
|
||||
render(
|
||||
<UsagePanel
|
||||
keys={[]}
|
||||
usage={[
|
||||
{ date: today, featureKind: 'chat', totalTokens: 3 },
|
||||
{ date: today, featureKind: 'transcript', totalTokens: 5 },
|
||||
]}
|
||||
onClearAll={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTitle('8 tokens')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('logByokError', () => {
|
||||
test('logs safe metadata without raw error message', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const error = Object.assign(
|
||||
new Error('authorization: Bearer token=a+b%2F=='),
|
||||
{
|
||||
code: 'BAD_REQUEST',
|
||||
status: 400,
|
||||
type: 'bad_request',
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
logByokError('byok', error);
|
||||
expect(warn).toHaveBeenCalledWith('byok', {
|
||||
name: 'Error',
|
||||
code: 'BAD_REQUEST',
|
||||
status: 400,
|
||||
type: 'bad_request',
|
||||
});
|
||||
expect(JSON.stringify(warn.mock.calls)).not.toContain('token=a+b%2F==');
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
import { Button, notify } from '@affine/component';
|
||||
import {
|
||||
SettingHeader,
|
||||
SettingWrapper,
|
||||
} from '@affine/component/setting-components';
|
||||
import { WorkspaceServerService } from '@affine/core/modules/cloud';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import {
|
||||
ByokKeyStorage,
|
||||
clearWorkspaceByokConfigsMutation as clearByokMutation,
|
||||
deleteWorkspaceByokConfigMutation as deleteByokMutation,
|
||||
type GraphQLQuery,
|
||||
workspaceByokSettingsQuery as byokSettingsQuery,
|
||||
} from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { AddKeyModal } from './add-key-modal';
|
||||
import { CoveragePanel } from './coverage';
|
||||
import { logByokError } from './errors';
|
||||
import * as styles from './index.css';
|
||||
import { KeyList } from './key-list';
|
||||
import {
|
||||
clearLocalKeys,
|
||||
deleteLocalKey,
|
||||
localByokStorageSupported,
|
||||
readLocalKeys,
|
||||
reorderLocalKeys,
|
||||
} from './local-storage';
|
||||
import { byokT } from './metadata';
|
||||
import type {
|
||||
ByokKey,
|
||||
ByokSettings,
|
||||
ByokStorage,
|
||||
ByokUsagePoint,
|
||||
GqlFn,
|
||||
} from './types';
|
||||
import { UsagePanel } from './usage';
|
||||
|
||||
const reorderByokMutation = {
|
||||
id: 'reorderWorkspaceByokConfigsMutation',
|
||||
op: 'reorderWorkspaceByokConfigs',
|
||||
query: `mutation reorderWorkspaceByokConfigs($input: ReorderWorkspaceByokConfigsInput!) {
|
||||
reorderWorkspaceByokConfigs(input: $input) {
|
||||
id
|
||||
sortOrder
|
||||
}
|
||||
}`,
|
||||
} satisfies GraphQLQuery;
|
||||
|
||||
export const WorkspaceByokSetting = () => {
|
||||
const t = useI18n();
|
||||
const workspace = useService(WorkspaceService).workspace;
|
||||
const workspaceServer = useService(WorkspaceServerService);
|
||||
const [settings, setSettings] = useState<ByokSettings | null>(null);
|
||||
const [usage, setUsage] = useState<ByokUsagePoint[]>([]);
|
||||
const [localKeys, setLocalKeys] = useState<ByokKey[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingKey, setEditingKey] = useState<ByokKey | null>(null);
|
||||
const [draggingKey, setDraggingKey] = useState<{
|
||||
id: string;
|
||||
storage: ByokStorage;
|
||||
} | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!workspaceServer.server) {
|
||||
return;
|
||||
}
|
||||
const to = new Date();
|
||||
const from = new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
const gql = workspaceServer.server.gql as GqlFn;
|
||||
const data = await gql({
|
||||
query: byokSettingsQuery,
|
||||
variables: {
|
||||
id: workspace.id,
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
},
|
||||
});
|
||||
const [localStorageSupported, nextLocalKeys] = await Promise.all([
|
||||
localByokStorageSupported(),
|
||||
readLocalKeys(workspace.id),
|
||||
]);
|
||||
setSettings({
|
||||
...data.workspace.byokSettings,
|
||||
localStorageSupported:
|
||||
data.workspace.byokSettings.localEntitled && localStorageSupported,
|
||||
});
|
||||
setUsage(data.workspace.byokUsage);
|
||||
setLocalKeys(nextLocalKeys);
|
||||
}, [workspace.id, workspaceServer.server]);
|
||||
|
||||
useEffect(() => {
|
||||
load().catch(error => {
|
||||
logByokError('Failed to load BYOK settings', error);
|
||||
notify.error({
|
||||
title: byokT(t, 'notify.load-failed.title'),
|
||||
message: byokT(t, 'notify.operation-failed.message'),
|
||||
});
|
||||
});
|
||||
}, [load, t]);
|
||||
|
||||
const keys = useMemo(() => {
|
||||
return [...localKeys, ...(settings?.keys ?? [])].toSorted((a, b) => {
|
||||
if (a.storage !== b.storage) {
|
||||
return a.storage === ByokKeyStorage.local ? -1 : 1;
|
||||
}
|
||||
return a.sortOrder - b.sortOrder;
|
||||
});
|
||||
}, [localKeys, settings?.keys]);
|
||||
const canAddServerKey = settings?.serverEntitled ?? false;
|
||||
const canAddLocalKey =
|
||||
(settings?.localEntitled ?? false) &&
|
||||
(settings?.localStorageSupported ?? false);
|
||||
const canManageKeys = canAddServerKey || canAddLocalKey;
|
||||
|
||||
const clearAll = useCallback(async () => {
|
||||
if (!settings) {
|
||||
return;
|
||||
}
|
||||
if (!workspaceServer.server && settings.serverEntitled) {
|
||||
return;
|
||||
}
|
||||
if (settings.serverEntitled && workspaceServer.server) {
|
||||
const gql = workspaceServer.server.gql as GqlFn;
|
||||
await gql({
|
||||
query: clearByokMutation,
|
||||
variables: { workspaceId: workspace.id },
|
||||
});
|
||||
}
|
||||
if (settings.localStorageSupported) {
|
||||
await clearLocalKeys(workspace.id);
|
||||
}
|
||||
setLocalKeys([]);
|
||||
await load();
|
||||
}, [load, settings, workspace.id, workspaceServer.server]);
|
||||
|
||||
const deleteKey = useCallback(
|
||||
async (key: ByokKey) => {
|
||||
if (key.storage === ByokKeyStorage.local) {
|
||||
await deleteLocalKey(workspace.id, key.id);
|
||||
setLocalKeys(await readLocalKeys(workspace.id));
|
||||
return;
|
||||
}
|
||||
const gql = workspaceServer.server?.gql as
|
||||
| ((input: {
|
||||
query: GraphQLQuery;
|
||||
variables?: Record<string, unknown>;
|
||||
}) => Promise<unknown>)
|
||||
| undefined;
|
||||
await gql?.({
|
||||
query: deleteByokMutation,
|
||||
variables: { workspaceId: workspace.id, id: key.id },
|
||||
});
|
||||
await load();
|
||||
},
|
||||
[load, workspace.id, workspaceServer.server]
|
||||
);
|
||||
|
||||
const reorderKey = useCallback(
|
||||
async (targetKey: ByokKey) => {
|
||||
if (!draggingKey || draggingKey.id === targetKey.id) {
|
||||
return;
|
||||
}
|
||||
if (draggingKey.storage !== targetKey.storage) {
|
||||
notify.error({
|
||||
title: byokT(t, 'notify.cross-storage-reorder.title'),
|
||||
message: byokT(t, 'notify.cross-storage-reorder.message'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const bucket = keys.filter(key => key.storage === targetKey.storage);
|
||||
const fromIndex = bucket.findIndex(key => key.id === draggingKey.id);
|
||||
const toIndex = bucket.findIndex(key => key.id === targetKey.id);
|
||||
if (fromIndex === -1 || toIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextBucket = [...bucket];
|
||||
const [moved] = nextBucket.splice(fromIndex, 1);
|
||||
nextBucket.splice(toIndex, 0, moved);
|
||||
const nextBucketIds = nextBucket.map(key => key.id);
|
||||
|
||||
if (targetKey.storage === ByokKeyStorage.local) {
|
||||
setLocalKeys(await reorderLocalKeys(workspace.id, nextBucketIds));
|
||||
return;
|
||||
}
|
||||
|
||||
const gql = workspaceServer.server?.gql as
|
||||
| ((input: {
|
||||
query: GraphQLQuery;
|
||||
variables?: Record<string, unknown>;
|
||||
}) => Promise<unknown>)
|
||||
| undefined;
|
||||
await gql?.({
|
||||
query: reorderByokMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId: workspace.id,
|
||||
storage: ByokKeyStorage.server,
|
||||
ids: nextBucketIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
await load();
|
||||
},
|
||||
[draggingKey, keys, load, t, workspace.id, workspaceServer.server]
|
||||
);
|
||||
|
||||
if (!settings) {
|
||||
return (
|
||||
<SettingHeader
|
||||
title={byokT(t, 'title-beta')}
|
||||
subtitle={byokT(t, 'loading')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!settings.entitled) {
|
||||
return (
|
||||
<>
|
||||
<SettingHeader
|
||||
title={byokT(t, 'title-beta')}
|
||||
subtitle={byokT(t, 'subtitle')}
|
||||
/>
|
||||
<SettingWrapper>
|
||||
<div className={styles.locked} data-testid="workspace-byok-locked">
|
||||
<div>
|
||||
<div className={styles.title}>{byokT(t, 'locked.title')}</div>
|
||||
<div className={styles.description}>
|
||||
{byokT(t, 'locked.description')}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.tags}>
|
||||
{settings.entitlementRequired.map(plan => (
|
||||
<span className={styles.tag} key={plan}>
|
||||
{plan}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SettingWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingHeader
|
||||
title={byokT(t, 'title-beta')}
|
||||
subtitle={byokT(t, 'header')}
|
||||
/>
|
||||
<SettingWrapper>
|
||||
<div className={styles.stack}>
|
||||
{settings.hasAiPlan ? (
|
||||
<div className={styles.notice}>
|
||||
<div className={styles.title}>{byokT(t, 'notice.title')}</div>
|
||||
<div className={styles.description}>
|
||||
{byokT(t, 'notice.description')}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={styles.panel} data-testid="workspace-byok-keys">
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<div className={styles.title}>{byokT(t, 'keys.title')}</div>
|
||||
<div className={styles.description}>
|
||||
{byokT(t, 'keys.description')}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canManageKeys}
|
||||
onClick={() => {
|
||||
setEditingKey(null);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{byokT(t, 'action.add-key')}
|
||||
</Button>
|
||||
</div>
|
||||
{keys.length ? (
|
||||
<KeyList
|
||||
keys={keys}
|
||||
onEdit={key => {
|
||||
setEditingKey(key);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
onDelete={key => {
|
||||
deleteKey(key).catch(error => {
|
||||
logByokError('Failed to delete BYOK key', error);
|
||||
notify.error({
|
||||
title: byokT(t, 'notify.delete-failed.title'),
|
||||
message: byokT(t, 'notify.operation-failed.message'),
|
||||
});
|
||||
});
|
||||
}}
|
||||
onDragStart={key => {
|
||||
setDraggingKey({ id: key.id, storage: key.storage });
|
||||
}}
|
||||
onDragEnd={() => setDraggingKey(null)}
|
||||
onDrop={key => {
|
||||
reorderKey(key).catch(error => {
|
||||
logByokError('Failed to reorder BYOK keys', error);
|
||||
notify.error({
|
||||
title: byokT(t, 'notify.reorder-failed.title'),
|
||||
message: byokT(t, 'notify.operation-failed.message'),
|
||||
});
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.empty} data-testid="workspace-byok-empty">
|
||||
<div className={styles.title}>{byokT(t, 'empty.title')}</div>
|
||||
<div className={styles.description}>
|
||||
{byokT(t, 'empty.description')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CoveragePanel keys={keys} settings={settings} />
|
||||
|
||||
<UsagePanel
|
||||
keys={keys}
|
||||
usage={usage}
|
||||
onClearAll={() => {
|
||||
clearAll().catch(error => {
|
||||
logByokError('Failed to clear BYOK keys', error);
|
||||
notify.error({
|
||||
title: byokT(t, 'notify.clear-failed.title'),
|
||||
message: byokT(t, 'notify.operation-failed.message'),
|
||||
});
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingWrapper>
|
||||
<AddKeyModal
|
||||
workspaceId={workspace.id}
|
||||
settings={settings}
|
||||
editingKey={editingKey}
|
||||
open={modalOpen}
|
||||
onOpenChange={open => {
|
||||
setModalOpen(open);
|
||||
if (!open) {
|
||||
setEditingKey(null);
|
||||
}
|
||||
}}
|
||||
onSaved={load}
|
||||
localKeys={localKeys}
|
||||
setLocalKeys={setLocalKeys}
|
||||
localStorageSupported={settings.localStorageSupported}
|
||||
canAddServerKey={canAddServerKey}
|
||||
canAddLocalKey={canAddLocalKey}
|
||||
gql={workspaceServer.server?.gql as GqlFn | undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import { DragHandle, IconButton } from '@affine/component';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { DeleteIcon, EditIcon } from '@blocksuite/icons/rc';
|
||||
import type { DragEvent } from 'react';
|
||||
|
||||
import * as styles from './index.css';
|
||||
import {
|
||||
byokT,
|
||||
capabilityLabel,
|
||||
providerLabels,
|
||||
rowDescription,
|
||||
storageLabel,
|
||||
} from './metadata';
|
||||
import type { ByokKey } from './types';
|
||||
|
||||
export const KeyList = ({
|
||||
keys,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDrop,
|
||||
}: {
|
||||
keys: ByokKey[];
|
||||
onEdit: (key: ByokKey) => void;
|
||||
onDelete: (key: ByokKey) => void;
|
||||
onDragStart: (key: ByokKey) => void;
|
||||
onDragEnd: () => void;
|
||||
onDrop: (key: ByokKey) => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<div className={styles.rows}>
|
||||
{keys.map(key => (
|
||||
<div
|
||||
className={`${styles.row} ${key.enabled ? '' : styles.rowDisabled}`}
|
||||
draggable
|
||||
key={`${key.storage}:${key.id}`}
|
||||
onDragStart={() => onDragStart(key)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={(event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onDrop={event => {
|
||||
event.preventDefault();
|
||||
onDrop(key);
|
||||
}}
|
||||
>
|
||||
<div className={styles.dragHandle} title={byokT(t, 'action.reorder')}>
|
||||
<DragHandle />
|
||||
</div>
|
||||
<div className={styles.rowMain}>
|
||||
<div className={styles.rowTitle}>
|
||||
{providerLabels[key.provider]} / {key.name}
|
||||
<span className={styles.tag}>{storageLabel(t, key.storage)}</span>
|
||||
{!key.enabled ? (
|
||||
<span className={`${styles.tag} ${styles.dangerTag}`}>
|
||||
{byokT(t, 'status.disabled-after-failure')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.rowDescription}>
|
||||
{rowDescription(t, key)}
|
||||
</div>
|
||||
<div className={styles.tags}>
|
||||
{key.capabilities.map(capability => (
|
||||
<span className={styles.tag} key={capability}>
|
||||
{capabilityLabel(t, capability)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.rowActions}>
|
||||
<IconButton
|
||||
size="20"
|
||||
title={byokT(t, 'action.edit')}
|
||||
icon={<EditIcon />}
|
||||
onClick={() => onEdit(key)}
|
||||
/>
|
||||
<IconButton
|
||||
size="20"
|
||||
title={byokT(t, 'action.delete')}
|
||||
icon={<DeleteIcon />}
|
||||
onClick={() => onDelete(key)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import { apis } from '@affine/electron-api';
|
||||
import { ByokKeyStorage, ByokKeyTestStatus } from '@affine/graphql';
|
||||
|
||||
import { capabilitiesFor } from './metadata';
|
||||
import type { ByokKey, LocalByokKeyInput, LocalByokPublicKey } from './types';
|
||||
|
||||
function byokStorageApi() {
|
||||
return BUILD_CONFIG.isElectron ? apis?.byokStorage : undefined;
|
||||
}
|
||||
|
||||
export async function localByokStorageSupported() {
|
||||
const storage = byokStorageApi();
|
||||
if (!storage) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return await storage.isSupported();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function toLocalByokKey(key: LocalByokPublicKey): ByokKey {
|
||||
return {
|
||||
id: key.id,
|
||||
provider: key.provider,
|
||||
name: key.name,
|
||||
description: key.description ?? null,
|
||||
storage: ByokKeyStorage.local,
|
||||
configured: key.configured ?? true,
|
||||
enabled: key.enabled ?? true,
|
||||
endpoint: key.endpoint ?? null,
|
||||
endpointEditable: key.endpointEditable ?? false,
|
||||
sortOrder: key.sortOrder ?? 0,
|
||||
capabilities: capabilitiesFor(key.provider, ByokKeyStorage.local),
|
||||
testStatus: key.testStatus ?? ByokKeyTestStatus.passed,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readLocalKeys(workspaceId: string): Promise<ByokKey[]> {
|
||||
const storage = byokStorageApi();
|
||||
if (!(await localByokStorageSupported()) || !storage) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const keys = (await storage.listWorkspaceKeys(
|
||||
workspaceId
|
||||
)) as LocalByokPublicKey[];
|
||||
return keys.map(toLocalByokKey);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertLocalKey(
|
||||
workspaceId: string,
|
||||
key: LocalByokKeyInput
|
||||
) {
|
||||
const storage = byokStorageApi();
|
||||
if (!(await localByokStorageSupported()) || !storage) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await storage.upsertWorkspaceKey(workspaceId, key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteLocalKey(workspaceId: string, keyId: string) {
|
||||
const storage = byokStorageApi();
|
||||
if (!(await localByokStorageSupported()) || !storage) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return await storage.deleteWorkspaceKey(workspaceId, keyId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function reorderLocalKeys(workspaceId: string, ids: string[]) {
|
||||
const storage = byokStorageApi();
|
||||
if (!(await localByokStorageSupported()) || !storage) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const keys = (await storage.reorderWorkspaceKeys(
|
||||
workspaceId,
|
||||
ids
|
||||
)) as LocalByokPublicKey[];
|
||||
return keys.map(toLocalByokKey);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearLocalKeys(workspaceId: string) {
|
||||
const storage = byokStorageApi();
|
||||
if (!(await localByokStorageSupported()) || !storage) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return await storage.clearWorkspaceKeys(workspaceId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import { ByokKeyStorage, ByokProvider } from '@affine/graphql';
|
||||
import type { I18nInstance } from '@affine/i18n';
|
||||
|
||||
import type { ByokKey, ByokStorage } from './types';
|
||||
|
||||
export function byokT(
|
||||
t: I18nInstance,
|
||||
key: string,
|
||||
options?: Record<string, unknown>
|
||||
) {
|
||||
return t.t('com.affine.settings.workspace.byok.' + key, options);
|
||||
}
|
||||
|
||||
export const providerLabels: Record<ByokProvider, string> = {
|
||||
[ByokProvider.openai]: 'OpenAI',
|
||||
[ByokProvider.anthropic]: 'Anthropic',
|
||||
[ByokProvider.gemini]: 'Gemini',
|
||||
[ByokProvider.fal]: 'FAL',
|
||||
};
|
||||
|
||||
export function storageLabel(t: I18nInstance, storage: ByokStorage) {
|
||||
return storage === ByokKeyStorage.local
|
||||
? byokT(t, 'storage.local')
|
||||
: byokT(t, 'storage.server');
|
||||
}
|
||||
|
||||
export function capabilitiesFor(provider: ByokProvider, storage: ByokStorage) {
|
||||
switch (provider) {
|
||||
case ByokProvider.openai:
|
||||
return ['Text', 'Image input', 'Actions', 'Image generate'];
|
||||
case ByokProvider.anthropic:
|
||||
return ['Text', 'Image input'];
|
||||
case ByokProvider.gemini:
|
||||
return storage === ByokKeyStorage.server
|
||||
? [
|
||||
'Text',
|
||||
'Image input',
|
||||
'Actions',
|
||||
'Image generate',
|
||||
'Transcript',
|
||||
'Indexing',
|
||||
]
|
||||
: ['Text', 'Image input', 'Actions', 'Image generate'];
|
||||
case ByokProvider.fal:
|
||||
return ['Image generate'];
|
||||
}
|
||||
}
|
||||
|
||||
export function capabilityLabel(t: I18nInstance, capability: string) {
|
||||
switch (capability) {
|
||||
case 'Text':
|
||||
return byokT(t, 'capability.text');
|
||||
case 'Image input':
|
||||
return byokT(t, 'capability.image-input');
|
||||
case 'Actions':
|
||||
return byokT(t, 'capability.actions');
|
||||
case 'Image generate':
|
||||
return byokT(t, 'capability.image-generate');
|
||||
case 'Transcript':
|
||||
return byokT(t, 'capability.transcript');
|
||||
case 'Indexing':
|
||||
return byokT(t, 'capability.indexing');
|
||||
default:
|
||||
return capability;
|
||||
}
|
||||
}
|
||||
|
||||
export const capabilityRows = [
|
||||
{
|
||||
titleKey: 'feature.chat.title',
|
||||
featureKind: 'chat',
|
||||
fallbackKey: 'feature.chat.fallback',
|
||||
icon: 'chat',
|
||||
providers: [
|
||||
ByokProvider.openai,
|
||||
ByokProvider.anthropic,
|
||||
ByokProvider.gemini,
|
||||
],
|
||||
coverageCapabilities: ['Text'],
|
||||
},
|
||||
{
|
||||
titleKey: 'feature.action.title',
|
||||
featureKind: 'action',
|
||||
fallbackKey: 'feature.action.fallback',
|
||||
icon: 'action',
|
||||
providers: [ByokProvider.openai, ByokProvider.gemini],
|
||||
coverageCapabilities: ['Actions'],
|
||||
},
|
||||
{
|
||||
titleKey: 'feature.image.title',
|
||||
featureKind: 'image',
|
||||
fallbackKey: 'feature.image.fallback',
|
||||
icon: 'image',
|
||||
providers: [ByokProvider.openai, ByokProvider.gemini, ByokProvider.fal],
|
||||
coverageCapabilities: ['Image generate'],
|
||||
},
|
||||
{
|
||||
titleKey: 'feature.transcript.title',
|
||||
featureKind: 'transcript',
|
||||
fallbackKey: 'feature.transcript.fallback',
|
||||
icon: 'transcript',
|
||||
providers: [ByokProvider.gemini],
|
||||
coverageCapabilities: ['Transcript'],
|
||||
storage: ByokKeyStorage.server,
|
||||
},
|
||||
{
|
||||
titleKey: 'feature.workspace-indexing.title',
|
||||
featureKind: 'workspace_indexing',
|
||||
fallbackKey: 'feature.workspace-indexing.fallback',
|
||||
icon: 'indexing',
|
||||
providers: [ByokProvider.gemini],
|
||||
coverageCapabilities: ['Indexing'],
|
||||
storage: ByokKeyStorage.server,
|
||||
},
|
||||
] as const;
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function rowDescription(t: I18nInstance, key: ByokKey) {
|
||||
const failed = formatDate(key.lastErrorAt);
|
||||
const used = formatDate(key.lastUsedAt);
|
||||
const today = formatDate(new Date().toISOString());
|
||||
const activity = failed
|
||||
? byokT(t, 'row.activity.failed', { date: failed })
|
||||
: used
|
||||
? used === today
|
||||
? byokT(t, 'row.activity.used-today')
|
||||
: byokT(t, 'row.activity.used', { date: used })
|
||||
: byokT(t, 'row.activity.unused');
|
||||
|
||||
return [storageLabel(t, key.storage), activity, key.description]
|
||||
.filter(Boolean)
|
||||
.join(' • ');
|
||||
}
|
||||
|
||||
export function warningDescription(
|
||||
t: I18nInstance,
|
||||
warning?: { featureKind: string; reason: string }
|
||||
) {
|
||||
if (!warning) {
|
||||
return null;
|
||||
}
|
||||
switch (warning.featureKind) {
|
||||
case 'transcript':
|
||||
return byokT(t, 'warning.transcript');
|
||||
case 'workspace_indexing':
|
||||
return byokT(t, 'warning.workspace-indexing');
|
||||
default:
|
||||
return warning.reason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
type ByokKeyStorage,
|
||||
type ByokKeyTestStatus,
|
||||
type ByokProvider,
|
||||
type GraphQLQuery,
|
||||
type QueryOptions,
|
||||
type QueryResponse,
|
||||
} from '@affine/graphql';
|
||||
|
||||
export type ByokStorage = ByokKeyStorage;
|
||||
|
||||
export type ByokKey = {
|
||||
id: string;
|
||||
provider: ByokProvider;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
storage: ByokStorage;
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
endpoint?: string | null;
|
||||
endpointEditable: boolean;
|
||||
sortOrder: number;
|
||||
capabilities: string[];
|
||||
testStatus: ByokKeyTestStatus;
|
||||
disabledReason?: string | null;
|
||||
lastTestedAt?: string | null;
|
||||
lastTestError?: string | null;
|
||||
lastUsedAt?: string | null;
|
||||
lastErrorAt?: string | null;
|
||||
lastError?: string | null;
|
||||
};
|
||||
|
||||
export type LocalByokKeyInput = Pick<
|
||||
ByokKey,
|
||||
| 'id'
|
||||
| 'provider'
|
||||
| 'name'
|
||||
| 'description'
|
||||
| 'endpoint'
|
||||
| 'sortOrder'
|
||||
| 'enabled'
|
||||
> & {
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
export type ByokSettings = {
|
||||
workspaceId: string;
|
||||
entitled: boolean;
|
||||
serverEntitled: boolean;
|
||||
localEntitled: boolean;
|
||||
entitlementRequired: string[];
|
||||
keys: ByokKey[];
|
||||
allowedProviders: ByokProvider[];
|
||||
localStorageSupported: boolean;
|
||||
customEndpointSupported: boolean;
|
||||
hasAiPlan: boolean;
|
||||
warnings: Array<{
|
||||
featureKind: string;
|
||||
reason: string;
|
||||
requiredProviders: ByokProvider[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ByokUsagePoint = {
|
||||
date: string;
|
||||
featureKind: string;
|
||||
totalTokens: number;
|
||||
};
|
||||
|
||||
export type ByokTestResult = {
|
||||
ok: boolean;
|
||||
status: ByokKey['testStatus'];
|
||||
message?: string | null;
|
||||
};
|
||||
|
||||
export type GqlFn = <Query extends GraphQLQuery>(
|
||||
input: QueryOptions<Query>
|
||||
) => Promise<QueryResponse<Query>>;
|
||||
|
||||
export type LocalByokPublicKey = {
|
||||
id: string;
|
||||
provider: ByokProvider;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
endpoint?: string | null;
|
||||
endpointEditable?: boolean;
|
||||
sortOrder?: number | null;
|
||||
enabled?: boolean | null;
|
||||
configured?: boolean;
|
||||
testStatus?: ByokKey['testStatus'];
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Button } from '@affine/component';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import * as styles from './index.css';
|
||||
import { byokT } from './metadata';
|
||||
import type { ByokKey, ByokUsagePoint } from './types';
|
||||
|
||||
export const UsagePanel = ({
|
||||
keys,
|
||||
usage,
|
||||
onClearAll,
|
||||
}: {
|
||||
keys: ByokKey[];
|
||||
usage: ByokUsagePoint[];
|
||||
onClearAll: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const dailyUsage = useMemo(() => {
|
||||
const totals = new Map<string, number>();
|
||||
for (const point of usage) {
|
||||
const day = point.date.slice(0, 10);
|
||||
totals.set(day, (totals.get(day) ?? 0) + point.totalTokens);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const todayUtc = Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate()
|
||||
);
|
||||
const startUtc = todayUtc - 29 * 24 * 60 * 60 * 1000;
|
||||
return Array.from({ length: 30 }).map((_, index) => {
|
||||
const day = new Date(startUtc + index * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
return totals.get(day) ?? 0;
|
||||
});
|
||||
}, [usage]);
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<div className={styles.title}>{byokT(t, 'usage.title')}</div>
|
||||
<div className={styles.description}>{byokT(t, 'usage.period')}</div>
|
||||
</div>
|
||||
<Button variant="error" disabled={!keys.length} onClick={onClearAll}>
|
||||
{byokT(t, 'action.clear-all')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.chart}>
|
||||
{dailyUsage.map((total, index) => {
|
||||
const height = Math.max(2, Math.min(120, total / 1000));
|
||||
return (
|
||||
<div
|
||||
className={styles.bar}
|
||||
key={index}
|
||||
style={{ height }}
|
||||
title={byokT(t, 'usage.tokens', { count: total })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+17
-10
@@ -1,8 +1,9 @@
|
||||
import { IntegrationTypeIcon } from '@affine/core/modules/integration';
|
||||
import type { I18nString } from '@affine/i18n';
|
||||
import { Logo1Icon, TodayIcon } from '@blocksuite/icons/rc';
|
||||
import { AiIcon, Logo1Icon, TodayIcon } from '@blocksuite/icons/rc';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { WorkspaceByokSetting } from '../byok';
|
||||
import { CalendarSettingPanel } from './calendar/setting-panel';
|
||||
import MCPIcon from './mcp-server/MCP.inline.svg';
|
||||
import { McpServerSettingPanel } from './mcp-server/setting-panel';
|
||||
@@ -14,14 +15,8 @@ type IntegrationCard = {
|
||||
desc: I18nString;
|
||||
icon: ReactNode;
|
||||
cloud?: boolean;
|
||||
} & (
|
||||
| {
|
||||
setting: ReactNode;
|
||||
}
|
||||
| {
|
||||
link: string;
|
||||
}
|
||||
);
|
||||
byok?: boolean;
|
||||
} & ({ setting: ReactNode } | { link: string });
|
||||
|
||||
const INTEGRATION_LIST = [
|
||||
{
|
||||
@@ -54,6 +49,14 @@ const INTEGRATION_LIST = [
|
||||
icon: <Logo1Icon />,
|
||||
link: 'https://chromewebstore.google.com/detail/affine-web-clipper/mpbbkmbdpleomiogkbkkpfoljjpahmoi',
|
||||
},
|
||||
{
|
||||
id: 'byok' as const,
|
||||
name: 'com.affine.settings.workspace.byok.title',
|
||||
desc: 'com.affine.settings.workspace.byok.subtitle',
|
||||
icon: <AiIcon />,
|
||||
setting: <WorkspaceByokSetting />,
|
||||
byok: true,
|
||||
},
|
||||
] satisfies (IntegrationCard | false)[];
|
||||
|
||||
type IntegrationId = Exclude<
|
||||
@@ -65,9 +68,13 @@ export type IntegrationItem = Exclude<IntegrationCard, 'id'> & {
|
||||
id: IntegrationId;
|
||||
};
|
||||
|
||||
export function getAllowedIntegrationList(isCloudWorkspace: boolean) {
|
||||
export function getAllowedIntegrationList(
|
||||
isCloudWorkspace: boolean,
|
||||
showByok: boolean
|
||||
) {
|
||||
return INTEGRATION_LIST.filter(item => {
|
||||
if (!item) return false;
|
||||
if ('byok' in item && item.byok && !showByok) return false;
|
||||
const requiredCloud = 'cloud' in item && item.cloud;
|
||||
if (requiredCloud && !isCloudWorkspace) return false;
|
||||
return true;
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const workspaceInfoState = vi.hoisted(() => ({
|
||||
info: {
|
||||
isOwner: false,
|
||||
isAdmin: false,
|
||||
isTeam: false,
|
||||
},
|
||||
}));
|
||||
const workspaceState = vi.hoisted(() => ({
|
||||
id: 'workspace-1',
|
||||
flavour: 'affine',
|
||||
}));
|
||||
|
||||
const WorkspaceServiceToken = vi.hoisted(() => class WorkspaceService {});
|
||||
|
||||
vi.mock('@affine/component/setting-components', () => ({
|
||||
SettingHeader: ({ title }: { title: string }) => <h1>{title}</h1>,
|
||||
}));
|
||||
|
||||
vi.mock('@affine/core/components/hooks/use-workspace-info', () => ({
|
||||
useWorkspaceInfo: () => workspaceInfoState.info,
|
||||
}));
|
||||
|
||||
vi.mock('@affine/core/modules/integration', () => ({
|
||||
IntegrationTypeIcon: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@affine/core/modules/workspace', () => ({
|
||||
WorkspaceService: WorkspaceServiceToken,
|
||||
}));
|
||||
|
||||
vi.mock('@affine/i18n', () => {
|
||||
const messages: Record<string, string> = {
|
||||
'com.affine.integration.integrations': 'Integrations',
|
||||
'com.affine.integration.setting.description': 'Integration settings',
|
||||
'com.affine.settings.workspace.byok.title': 'AI BYOK',
|
||||
'com.affine.settings.workspace.byok.subtitle':
|
||||
'Use your own provider keys for this workspace.',
|
||||
};
|
||||
const translate = (key: string) => messages[key] ?? key;
|
||||
return {
|
||||
useI18n: () =>
|
||||
new Proxy(
|
||||
{
|
||||
t: translate,
|
||||
},
|
||||
{
|
||||
get: (target, key: string) => {
|
||||
if (key in target) {
|
||||
return target[key as keyof typeof target];
|
||||
}
|
||||
return () => translate(key);
|
||||
},
|
||||
}
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@blocksuite/icons/rc', () => ({
|
||||
AiIcon: () => null,
|
||||
Logo1Icon: () => null,
|
||||
TodayIcon: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@toeverything/infra', () => ({
|
||||
useService: (token: unknown) => {
|
||||
if (token === WorkspaceServiceToken) {
|
||||
return {
|
||||
workspace: workspaceState,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../byok', () => ({
|
||||
WorkspaceByokSetting: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../../sub-page', () => ({
|
||||
SubPageProvider: ({ children }: { children: ReactNode }) => children,
|
||||
useSubPageIsland: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('./calendar/setting-panel', () => ({
|
||||
CalendarSettingPanel: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('./mcp-server/setting-panel', () => ({
|
||||
McpServerSettingPanel: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('./readwise/setting-panel', () => ({
|
||||
ReadwiseSettingPanel: () => null,
|
||||
}));
|
||||
|
||||
import { IntegrationSetting } from '.';
|
||||
|
||||
describe('IntegrationSetting', () => {
|
||||
beforeEach(() => {
|
||||
workspaceInfoState.info = {
|
||||
isOwner: false,
|
||||
isAdmin: false,
|
||||
isTeam: false,
|
||||
};
|
||||
workspaceState.flavour = 'affine';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const byokVisibilityCases = [
|
||||
{
|
||||
name: 'ordinary members',
|
||||
info: { isOwner: false, isAdmin: false, isTeam: false },
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
name: 'owners',
|
||||
info: { isOwner: true, isAdmin: false, isTeam: false },
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
name: 'admins in personal workspaces',
|
||||
info: { isOwner: false, isAdmin: true, isTeam: false },
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
name: 'admins in team workspaces',
|
||||
info: { isOwner: false, isAdmin: true, isTeam: true },
|
||||
visible: true,
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of byokVisibilityCases) {
|
||||
test(`shows BYOK integration for ${testCase.name}`, () => {
|
||||
workspaceInfoState.info = testCase.info;
|
||||
render(<IntegrationSetting />);
|
||||
|
||||
if (testCase.visible) {
|
||||
expect(screen.getByText('AI BYOK')).not.toBeNull();
|
||||
} else {
|
||||
expect(screen.queryByText('AI BYOK')).toBeNull();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
+5
-2
@@ -1,4 +1,5 @@
|
||||
import { SettingHeader } from '@affine/component/setting-components';
|
||||
import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useService } from '@toeverything/infra';
|
||||
@@ -29,11 +30,13 @@ export const IntegrationSetting = ({
|
||||
const t = useI18n();
|
||||
const [opened, setOpened] = useState<string | null>(null);
|
||||
const workspaceService = useService(WorkspaceService);
|
||||
const info = useWorkspaceInfo(workspaceService.workspace);
|
||||
const isCloudWorkspace = workspaceService.workspace.flavour !== 'local';
|
||||
const showByok = isCloudWorkspace && !!(info?.isOwner || info?.isAdmin);
|
||||
|
||||
const integrationList = useMemo(
|
||||
() => getAllowedIntegrationList(isCloudWorkspace),
|
||||
[isCloudWorkspace]
|
||||
() => getAllowedIntegrationList(isCloudWorkspace, showByok),
|
||||
[isCloudWorkspace, showByok]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+126
-1
@@ -1,10 +1,12 @@
|
||||
/* eslint-disable rxjs/finnish */
|
||||
import type { CopilotChatHistoryFragment } from '@affine/graphql';
|
||||
import { type CopilotChatHistoryFragment } from '@affine/graphql';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getChatContentKey,
|
||||
resolveInitialSession,
|
||||
type SessionService,
|
||||
shouldResetChatPanelOnUserInfoChange,
|
||||
type WorkbenchLike,
|
||||
} from './chat-panel-session';
|
||||
|
||||
@@ -20,6 +22,129 @@ const createWorkbench = (search: string) => {
|
||||
|
||||
const doc = { id: 'doc-1', workspace: { id: 'ws-1' } };
|
||||
|
||||
describe('getChatContentKey', () => {
|
||||
const cases = [
|
||||
{
|
||||
name: 'uses doc id before a session is created',
|
||||
input: {
|
||||
docId: 'doc-1',
|
||||
hasPinned: false,
|
||||
session: null,
|
||||
},
|
||||
expected: 'doc-1',
|
||||
},
|
||||
{
|
||||
name: 'keeps a new empty doc session on the doc key',
|
||||
input: {
|
||||
docId: 'doc-2',
|
||||
hasPinned: false,
|
||||
previousSessionDocId: 'doc-1',
|
||||
previousSessionId: 'session-1',
|
||||
session: {
|
||||
sessionId: 'session-2',
|
||||
docId: 'doc-2',
|
||||
messages: [],
|
||||
},
|
||||
},
|
||||
expected: 'doc-2',
|
||||
},
|
||||
{
|
||||
name: 'uses session id for a session with history',
|
||||
input: {
|
||||
docId: 'doc-1',
|
||||
hasPinned: false,
|
||||
session: {
|
||||
sessionId: 'session-1',
|
||||
docId: 'doc-1',
|
||||
messages: [{ id: 'message-1' }],
|
||||
},
|
||||
},
|
||||
expected: 'session-1',
|
||||
},
|
||||
{
|
||||
name: 'uses session id for a pinned session',
|
||||
input: {
|
||||
docId: 'doc-1',
|
||||
hasPinned: true,
|
||||
session: {
|
||||
sessionId: 'session-1',
|
||||
docId: 'doc-1',
|
||||
messages: [],
|
||||
},
|
||||
},
|
||||
expected: 'session-1',
|
||||
},
|
||||
{
|
||||
name: 'uses session id for same-doc session switch',
|
||||
input: {
|
||||
docId: 'doc-1',
|
||||
hasPinned: false,
|
||||
previousSessionDocId: 'doc-1',
|
||||
previousSessionId: 'session-1',
|
||||
session: {
|
||||
sessionId: 'session-2',
|
||||
docId: 'doc-1',
|
||||
messages: [],
|
||||
},
|
||||
},
|
||||
expected: 'session-2',
|
||||
},
|
||||
] satisfies {
|
||||
name: string;
|
||||
input: Parameters<typeof getChatContentKey>[0];
|
||||
expected: string;
|
||||
}[];
|
||||
|
||||
test.each(cases)('$name', ({ input, expected }) => {
|
||||
expect(getChatContentKey(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldResetChatPanelOnUserInfoChange', () => {
|
||||
const cases = [
|
||||
{
|
||||
name: 'ignores the initial user info emission',
|
||||
input: {
|
||||
previousUserId: undefined,
|
||||
nextUserId: 'user-1',
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: 'ignores same-user refreshes',
|
||||
input: {
|
||||
previousUserId: 'user-1',
|
||||
nextUserId: 'user-1',
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: 'resets when the effective user changes',
|
||||
input: {
|
||||
previousUserId: 'user-1',
|
||||
nextUserId: 'user-2',
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: 'resets when the effective user signs out',
|
||||
input: {
|
||||
previousUserId: 'user-1',
|
||||
nextUserId: null,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
] satisfies {
|
||||
name: string;
|
||||
input: Parameters<typeof shouldResetChatPanelOnUserInfoChange>[0];
|
||||
expected: boolean;
|
||||
}[];
|
||||
|
||||
test.each(cases)('$name', ({ input, expected }) => {
|
||||
expect(shouldResetChatPanelOnUserInfoChange(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
test('returns undefined without session service or doc', async () => {
|
||||
await expect(
|
||||
resolveInitialSession({ sessionService: null, doc, workbench: null })
|
||||
|
||||
+51
@@ -43,6 +43,57 @@ export interface DocLike {
|
||||
};
|
||||
}
|
||||
|
||||
interface ChatContentKeySession {
|
||||
sessionId?: string | null;
|
||||
docId?: string | null;
|
||||
messages?: readonly unknown[] | null;
|
||||
}
|
||||
|
||||
export const shouldResetChatPanelOnUserInfoChange = ({
|
||||
previousUserId,
|
||||
nextUserId,
|
||||
}: {
|
||||
previousUserId?: string | null;
|
||||
nextUserId?: string | null;
|
||||
}) => {
|
||||
return previousUserId !== undefined && previousUserId !== nextUserId;
|
||||
};
|
||||
|
||||
export const getChatContentKey = ({
|
||||
docId,
|
||||
hasPinned,
|
||||
previousSessionDocId,
|
||||
previousSessionId,
|
||||
session,
|
||||
}: {
|
||||
docId?: string | null;
|
||||
hasPinned: boolean;
|
||||
previousSessionDocId?: string | null;
|
||||
previousSessionId?: string | null;
|
||||
session?: ChatContentKeySession | null;
|
||||
}) => {
|
||||
const fallbackKey = docId ?? 'chat-panel';
|
||||
const sessionId = session?.sessionId;
|
||||
if (!sessionId) {
|
||||
return fallbackKey;
|
||||
}
|
||||
|
||||
const sessionDocId = session.docId ?? docId ?? null;
|
||||
const hasSessionHistory = !!session.messages?.length;
|
||||
const sessionSwitchedWithinDoc = !!(
|
||||
previousSessionId &&
|
||||
previousSessionId !== sessionId &&
|
||||
previousSessionDocId &&
|
||||
sessionDocId &&
|
||||
previousSessionDocId === sessionDocId &&
|
||||
sessionDocId === docId
|
||||
);
|
||||
|
||||
return hasPinned || hasSessionHistory || sessionSwitchedWithinDoc
|
||||
? sessionId
|
||||
: fallbackKey;
|
||||
};
|
||||
|
||||
export const getSessionIdFromUrl = (workbench?: WorkbenchLike | null) => {
|
||||
if (!workbench) {
|
||||
return undefined;
|
||||
|
||||
@@ -48,7 +48,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createSessionDeleteHandler } from '../../chat-panel-utils';
|
||||
import * as styles from './chat.css';
|
||||
import {
|
||||
getChatContentKey,
|
||||
resolveInitialSession,
|
||||
shouldResetChatPanelOnUserInfoChange,
|
||||
type WorkbenchLike,
|
||||
} from './chat-panel-session';
|
||||
|
||||
@@ -98,8 +100,10 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
||||
const chatToolbarContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const contentKeyRef = useRef<string | null>(null);
|
||||
const prevSessionIdRef = useRef<string | null>(null);
|
||||
const prevSessionDocIdRef = useRef<string | null>(null);
|
||||
const lastDocIdRef = useRef<string | null>(null);
|
||||
const sessionLoadSeqRef = useRef(0);
|
||||
const userIdRef = useRef<string | null | undefined>(undefined);
|
||||
|
||||
const doc = editor?.doc;
|
||||
const host = editor?.host;
|
||||
@@ -346,11 +350,31 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
||||
}, [chatContent, chatToolbar, session]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AIProvider.slots.userInfo.subscribe(() => {
|
||||
let disposed = false;
|
||||
Promise.resolve(AIProvider.userInfo)
|
||||
.then(userInfo => {
|
||||
if (!disposed && userIdRef.current === undefined) {
|
||||
userIdRef.current = userInfo?.id ?? null;
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
const subscription = AIProvider.slots.userInfo.subscribe(userInfo => {
|
||||
const nextUserId = userInfo?.id ?? null;
|
||||
const shouldReset = shouldResetChatPanelOnUserInfoChange({
|
||||
previousUserId: userIdRef.current,
|
||||
nextUserId,
|
||||
});
|
||||
userIdRef.current = nextUserId;
|
||||
if (!shouldReset) {
|
||||
return;
|
||||
}
|
||||
resetPanel();
|
||||
initPanel().catch(console.error);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
return () => {
|
||||
disposed = true;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [initPanel, resetPanel]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -383,22 +407,20 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
||||
return () => subscription.unsubscribe();
|
||||
}, [doc, initPanel, session]);
|
||||
|
||||
const hasSessionHistory = !!session?.messages?.length;
|
||||
const sessionSwitched = !!(
|
||||
session?.sessionId &&
|
||||
prevSessionIdRef.current &&
|
||||
prevSessionIdRef.current !== session.sessionId
|
||||
);
|
||||
const contentKey =
|
||||
hasPinned || (session?.sessionId && (hasSessionHistory || sessionSwitched))
|
||||
? (session?.sessionId ?? doc?.id ?? 'chat-panel')
|
||||
: (doc?.id ?? 'chat-panel');
|
||||
const contentKey = getChatContentKey({
|
||||
docId: doc?.id,
|
||||
hasPinned,
|
||||
previousSessionDocId: prevSessionDocIdRef.current,
|
||||
previousSessionId: prevSessionIdRef.current,
|
||||
session,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.sessionId) {
|
||||
prevSessionIdRef.current = session.sessionId;
|
||||
prevSessionDocIdRef.current = session.docId ?? doc?.id ?? null;
|
||||
}
|
||||
}, [session?.sessionId]);
|
||||
}, [doc?.id, session?.docId, session?.sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatContent) {
|
||||
|
||||
@@ -14,7 +14,7 @@ export type SettingTab =
|
||||
| 'editor'
|
||||
| 'account'
|
||||
| 'meetings'
|
||||
| `workspace:${'preference' | 'properties' | 'members' | 'storage' | 'billing' | 'license' | 'integrations' | 'embedding' | 'search'}`;
|
||||
| `workspace:${'preference' | 'properties' | 'members' | 'storage' | 'billing' | 'license' | 'integrations' | 'embedding' | 'byok' | 'search'}`;
|
||||
|
||||
export type GLOBAL_DIALOG_SCHEMA = {
|
||||
'create-workspace': (props: { serverId?: string }) => {
|
||||
|
||||
Reference in New Issue
Block a user