feat(server): refactor for byok (#14911)

This commit is contained in:
DarkSky
2026-05-07 04:03:14 +08:00
committed by GitHub
parent 4e169ea5c7
commit eb9cc22502
115 changed files with 10369 additions and 1256 deletions
@@ -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,
@@ -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>
);
};
@@ -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));
}
@@ -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',
});
@@ -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}
/>
</>
);
};
@@ -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>
);
};
@@ -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;
}
}
@@ -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>
);
};
@@ -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;
@@ -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();
}
});
}
});
@@ -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(() => {
@@ -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 })
@@ -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 }) => {