From 9fee8147cbc355c78f55db2d98071844b911d715 Mon Sep 17 00:00:00 2001 From: akumatus Date: Thu, 15 May 2025 06:29:37 +0000 Subject: [PATCH] feat(core): add ai model switch ui (#12266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close [AI-86](https://linear.app/affine-design/issue/AI-86) ![截屏2025-05-14 11.32.41.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/sJGviKxfE3Ap685cl5bj/b92d5c32-fa5a-4afd-93e6-3699347575be.png) ## Summary by CodeRabbit - **New Features** - Introduced AI model switching in chat, allowing users to select from multiple AI models during conversations. - Added a floating menu for easy AI model selection within the chat interface. - Enabled visibility of the AI model switcher through a new experimental feature flag, configurable in workspace settings (canary builds only). - **Enhancements** - Improved session management in the chat panel for smoother model switching and state handling. - Updated localization to support the new AI model switch feature in settings. - **Bug Fixes** - None. - **Chores** - Registered new components and services to support AI model switching functionality. --- .../src/plugins/copilot/prompt/prompts.ts | 1 + .../plugins/copilot/providers/anthropic.ts | 12 +- .../src/plugins/copilot/providers/openai.ts | 16 ++- .../src/blocksuite/ai/chat-panel/index.ts | 50 +++++--- .../ai-chat-composer/ai-chat-composer.ts | 10 ++ .../components/ai-chat-input/ai-chat-input.ts | 26 ++++ .../ai/components/ai-chat-input/type.ts | 4 + .../ai-chat-models/ai-chat-models.ts | 112 ++++++++++++++++++ .../ai/components/ai-chat-models/index.ts | 1 + .../core/src/blocksuite/ai/effects.ts | 2 + .../hooks/affine/use-ai-chat-config.ts | 7 ++ .../pages/workspace/detail-page/tabs/chat.tsx | 3 + .../core/src/modules/ai-button/index.ts | 5 + .../ai-button/services/model-switch.ts | 26 ++++ .../core/src/modules/feature-flag/constant.ts | 9 ++ packages/frontend/core/src/modules/index.ts | 2 + packages/frontend/i18n/src/i18n.gen.ts | 8 ++ packages/frontend/i18n/src/resources/en.json | 2 + 18 files changed, 271 insertions(+), 25 deletions(-) create mode 100644 packages/frontend/core/src/blocksuite/ai/components/ai-chat-models/ai-chat-models.ts create mode 100644 packages/frontend/core/src/blocksuite/ai/components/ai-chat-models/index.ts create mode 100644 packages/frontend/core/src/modules/ai-button/services/model-switch.ts diff --git a/packages/backend/server/src/plugins/copilot/prompt/prompts.ts b/packages/backend/server/src/plugins/copilot/prompt/prompts.ts index 78e3ff5501..402b535f90 100644 --- a/packages/backend/server/src/plugins/copilot/prompt/prompts.ts +++ b/packages/backend/server/src/plugins/copilot/prompt/prompts.ts @@ -1066,6 +1066,7 @@ const chat: Prompt[] = [ name: 'Chat With AFFiNE AI', model: 'gpt-4.1', optionalModels: [ + 'gpt-4.1', 'o3', 'o4-mini', 'claude-3-7-sonnet-20250219', diff --git a/packages/backend/server/src/plugins/copilot/providers/anthropic.ts b/packages/backend/server/src/plugins/copilot/providers/anthropic.ts index 49d08fc4d2..d4b1eae157 100644 --- a/packages/backend/server/src/plugins/copilot/providers/anthropic.ts +++ b/packages/backend/server/src/plugins/copilot/providers/anthropic.ts @@ -135,7 +135,7 @@ export class AnthropicProvider messages: msgs, abortSignal: options.signal, providerOptions: { - anthropic: this.getAnthropicOptions(options), + anthropic: this.getAnthropicOptions(options, model), }, tools: this.getTools(), maxSteps: this.MAX_STEPS, @@ -167,7 +167,7 @@ export class AnthropicProvider messages: msgs, abortSignal: options.signal, providerOptions: { - anthropic: this.getAnthropicOptions(options), + anthropic: this.getAnthropicOptions(options, model), }, tools: this.getTools(), maxSteps: this.MAX_STEPS, @@ -256,9 +256,9 @@ export class AnthropicProvider }; } - private getAnthropicOptions(options: CopilotChatOptions) { + private getAnthropicOptions(options: CopilotChatOptions, model: string) { const result: AnthropicProviderOptions = {}; - if (options?.reasoning) { + if (options?.reasoning && this.isThinkingModel(model)) { result.thinking = { type: 'enabled', budgetTokens: 12000, @@ -282,4 +282,8 @@ export class AnthropicProvider private markAsCallout(text: string) { return text.replaceAll('\n', '\n> '); } + + private isThinkingModel(model: string) { + return model.startsWith('claude-3-7-sonnet'); + } } diff --git a/packages/backend/server/src/plugins/copilot/providers/openai.ts b/packages/backend/server/src/plugins/copilot/providers/openai.ts index 3394a33957..c998ce2322 100644 --- a/packages/backend/server/src/plugins/copilot/providers/openai.ts +++ b/packages/backend/server/src/plugins/copilot/providers/openai.ts @@ -200,8 +200,7 @@ export class OpenAIProvider for (const tool of options.tools) { switch (tool) { case 'webSearch': { - // o series reasoning models - if (model.startsWith('o')) { + if (this.isReasoningModel(model)) { tools.web_search_exa = createExaSearchTool(this.AFFiNEConfig); tools.web_crawl_exa = createExaCrawlTool(this.AFFiNEConfig); } else { @@ -251,7 +250,7 @@ export class OpenAIProvider : await generateText({ ...commonParams, providerOptions: { - openai: this.getOpenAIOptions(options), + openai: this.getOpenAIOptions(options, model), }, tools: this.getTools(options, model), maxSteps: this.MAX_STEPS, @@ -284,7 +283,7 @@ export class OpenAIProvider system, messages: msgs, providerOptions: { - openai: this.getOpenAIOptions(options), + openai: this.getOpenAIOptions(options, model), }, tools: tools as OpenAITools, maxSteps: this.MAX_STEPS, @@ -454,9 +453,9 @@ export class OpenAIProvider } } - private getOpenAIOptions(options: CopilotChatOptions) { + private getOpenAIOptions(options: CopilotChatOptions, model: string) { const result: OpenAIResponsesProviderOptions = {}; - if (options?.reasoning) { + if (options?.reasoning && this.isReasoningModel(model)) { result.reasoningEffort = 'medium'; result.reasoningSummary = 'detailed'; } @@ -481,4 +480,9 @@ export class OpenAIProvider private markAsCallout(text: string) { return text.replaceAll('\n', '\n> '); } + + private isReasoningModel(model: string) { + // o series reasoning models + return model.startsWith('o'); + } } diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts index 931976a504..33df0497cd 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts @@ -1,7 +1,7 @@ import './chat-panel-messages'; import type { FeatureFlagService } from '@affine/core/modules/feature-flag'; -import type { ContextEmbedStatus } from '@affine/graphql'; +import type { ContextEmbedStatus, CopilotSessionType } from '@affine/graphql'; import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit'; import type { EditorHost } from '@blocksuite/affine/std'; import { ShadowlessElement } from '@blocksuite/affine/std'; @@ -18,6 +18,7 @@ import type { SearchMenuConfig, } from '../components/ai-chat-chips'; import type { + AIModelSwitchConfig, AINetworkSearchConfig, AIReasoningConfig, } from '../components/ai-chat-input'; @@ -155,8 +156,8 @@ export class ChatPanel extends SignalWatcher( }; private readonly _getSessionId = async () => { - if (this._sessionId) { - return this._sessionId; + if (this.session) { + return this.session.id; } const sessions = ( (await AIProvider.session?.getSessions( @@ -164,22 +165,33 @@ export class ChatPanel extends SignalWatcher( this.doc.id, { action: false } )) || [] - ).filter(session => !session.parentSessionId); - const sessionId = sessions.at(-1)?.id; - this._sessionId = sessionId; - return this._sessionId; + ).filter(session => { + if (this.parentSessionId) { + return session.parentSessionId === this.parentSessionId; + } else { + return !session.parentSessionId; + } + }); + this.session = sessions.at(-1); + return this.session?.id; }; private readonly _createSessionId = async () => { - if (this._sessionId) { - return this._sessionId; + if (this.session) { + return this.session.id; } - this._sessionId = await AIProvider.session?.createSession({ + const sessionId = await AIProvider.session?.createSession({ docId: this.doc.id, workspaceId: this.doc.workspace.id, promptName: 'Chat With AFFiNE AI', }); - return this._sessionId; + if (sessionId) { + this.session = await AIProvider.session?.getSession( + this.doc.workspace.id, + sessionId + ); + } + return sessionId; }; @property({ attribute: false }) @@ -194,6 +206,9 @@ export class ChatPanel extends SignalWatcher( @property({ attribute: false }) accessor reasoningConfig!: AIReasoningConfig; + @property({ attribute: false }) + accessor modelSwitchConfig!: AIModelSwitchConfig; + @property({ attribute: false }) accessor appSidebarConfig!: AppSidebarConfig; @@ -209,6 +224,9 @@ export class ChatPanel extends SignalWatcher( @property({ attribute: false }) accessor affineFeatureFlagService!: FeatureFlagService; + @property({ attribute: false }) + accessor parentSessionId: string | undefined = undefined; + @state() accessor isLoading = false; @@ -218,10 +236,10 @@ export class ChatPanel extends SignalWatcher( @state() accessor embeddingProgress: [number, number] = [0, 0]; - private _isInitialized = false; + @state() + accessor session: CopilotSessionType | undefined = undefined; - // always use getSessionId to get the sessionId - private _sessionId: string | undefined = undefined; + private _isInitialized = false; private _isSidebarOpen: Signal = signal(false); @@ -252,7 +270,7 @@ export class ChatPanel extends SignalWatcher( }; private readonly _resetPanel = () => { - this._sessionId = undefined; + this.session = undefined; this.chatContextValue = DEFAULT_CHAT_CONTEXT_VALUE; this.isLoading = false; this._isInitialized = false; @@ -408,6 +426,7 @@ export class ChatPanel extends SignalWatcher( Promise; @@ -85,6 +90,9 @@ export class AIChatComposer extends SignalWatcher( @property({ attribute: false }) accessor searchMenuConfig!: SearchMenuConfig; + @property({ attribute: false }) + accessor modelSwitchConfig!: AIModelSwitchConfig; + @property({ attribute: false }) accessor onChatSuccess: (() => void) | undefined; @@ -124,6 +132,7 @@ export class AIChatComposer extends SignalWatcher( Upload + ${this.modelSwitchConfig.visible.value + ? html` + + ` + : nothing} ${this.networkSearchConfig.visible.value ? html`
{ + this.modelId = modelId; + }; + send = async (text: string) => { try { const { status, markdown, images } = this.chatContextValue; @@ -582,6 +607,7 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) { control: this.trackOptions.control, webSearch: this._isNetworkActive, reasoning: this._isReasoningActive, + modelId: this.modelId, }); for await (const text of stream) { diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/type.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/type.ts index 2e20d30a33..6d26d96152 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/type.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/type.ts @@ -14,6 +14,10 @@ export interface AIReasoningConfig { setEnabled: (state: boolean) => void; } +export interface AIModelSwitchConfig { + visible: Signal; +} + // TODO: remove this type export type AIChatInputContext = { messages: HistoryMessage[]; diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-models/ai-chat-models.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-models/ai-chat-models.ts new file mode 100644 index 0000000000..ce638500e1 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-models/ai-chat-models.ts @@ -0,0 +1,112 @@ +import type { CopilotSessionType } from '@affine/graphql'; +import { createLitPortal } from '@blocksuite/affine/components/portal'; +import { WithDisposable } from '@blocksuite/affine/global/lit'; +import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme'; +import { ShadowlessElement } from '@blocksuite/affine/std'; +import { flip, offset } from '@floating-ui/dom'; +import { css, html, nothing } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +export class AIChatModels extends WithDisposable(ShadowlessElement) { + @property() + accessor modelId: string | undefined = undefined; + + @property({ attribute: false }) + accessor onModelChange: ((modelId: string) => void) | undefined; + + @property({ attribute: false }) + accessor session!: CopilotSessionType | undefined; + + @query('.ai-chat-models') + accessor modelsButton!: HTMLDivElement; + + private _abortController: AbortController | null = null; + + static override styles = css` + ai-chat-models { + font-size: 13px; + cursor: pointer; + } + `; + + private readonly _onItemClick = (modelId: string) => { + this.onModelChange?.(modelId); + this._abortController?.abort(); + this._abortController = null; + }; + + private readonly _toggleSwitchModelMenu = () => { + if (this._abortController) { + this._abortController.abort(); + return; + } + + this._abortController = new AbortController(); + this._abortController.signal.addEventListener('abort', () => { + this._abortController = null; + }); + + createLitPortal({ + template: html` +
+ ${repeat( + this.session?.optionalModels ?? [], + modelId => modelId, + modelId => { + return html`
this._onItemClick(modelId)} + > + ${modelId} +
`; + } + )} +
`, + portalStyles: { + zIndex: 'var(--affine-z-index-popover)', + }, + container: document.body, + computePosition: { + referenceElement: this.modelsButton, + placement: 'top-start', + middleware: [offset({ crossAxis: -30, mainAxis: 8 }), flip()], + autoUpdate: { animationFrame: true }, + }, + abortController: this._abortController, + closeOnClickAway: true, + }); + }; + + override render() { + if (!this.session) { + return nothing; + } + + return html` +
+ ${this.modelId || this.session.model} +
+ `; + } +} diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-models/index.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-models/index.ts new file mode 100644 index 0000000000..d93f06b602 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-models/index.ts @@ -0,0 +1 @@ +export * from './ai-chat-models'; diff --git a/packages/frontend/core/src/blocksuite/ai/effects.ts b/packages/frontend/core/src/blocksuite/ai/effects.ts index 2b4c9bd847..bf95f9b87b 100644 --- a/packages/frontend/core/src/blocksuite/ai/effects.ts +++ b/packages/frontend/core/src/blocksuite/ai/effects.ts @@ -38,6 +38,7 @@ import { ChatPanelFileChip } from './components/ai-chat-chips/file-chip'; import { ChatPanelTagChip } from './components/ai-chat-chips/tag-chip'; import { AIChatComposer } from './components/ai-chat-composer'; import { AIChatInput } from './components/ai-chat-input'; +import { AIChatModels } from './components/ai-chat-models/ai-chat-models'; import { AIHistoryClear } from './components/ai-history-clear'; import { effects as componentAiItemEffects } from './components/ai-item'; import { AIScrollableTextRenderer } from './components/ai-scrollable-text-renderer'; @@ -110,6 +111,7 @@ export function registerAIEffects() { customElements.define('chat-panel-tag-chip', ChatPanelTagChip); customElements.define('chat-panel-collection-chip', ChatPanelCollectionChip); customElements.define('chat-panel-chip', ChatPanelChip); + customElements.define('ai-chat-models', AIChatModels); customElements.define('ai-error-wrapper', AIErrorWrapper); customElements.define('ai-slides-renderer', AISlidesRenderer); customElements.define('ai-answer-wrapper', AIAnswerWrapper); diff --git a/packages/frontend/core/src/components/hooks/affine/use-ai-chat-config.ts b/packages/frontend/core/src/components/hooks/affine/use-ai-chat-config.ts index 898dea5972..9f6784dba5 100644 --- a/packages/frontend/core/src/components/hooks/affine/use-ai-chat-config.ts +++ b/packages/frontend/core/src/components/hooks/affine/use-ai-chat-config.ts @@ -1,4 +1,5 @@ // packages/frontend/core/src/blocksuite/ai/hooks/useChatPanelConfig.ts +import { AIModelSwitchService } from '@affine/core/modules/ai-button/services/model-switch'; import { AINetworkSearchService } from '@affine/core/modules/ai-button/services/network-search'; import { AIReasoningService } from '@affine/core/modules/ai-button/services/reasoning'; import { CollectionService } from '@affine/core/modules/collection'; @@ -21,6 +22,7 @@ export function useAIChatConfig() { const searchService = framework.get(AINetworkSearchService); const reasoningService = framework.get(AIReasoningService); + const modelSwitchService = framework.get(AIModelSwitchService); const docDisplayMetaService = framework.get(DocDisplayMetaService); const workspaceService = framework.get(WorkspaceService); const searchMenuService = framework.get(SearchMenuService); @@ -40,6 +42,10 @@ export function useAIChatConfig() { setEnabled: reasoningService.setEnabled, }; + const modelSwitchConfig = { + visible: modelSwitchService.visible, + }; + const docDisplayConfig = { getIcon: (docId: string) => { return docDisplayMetaService.icon$(docId, { type: 'lit' }).value; @@ -124,5 +130,6 @@ export function useAIChatConfig() { reasoningConfig, docDisplayConfig, searchMenuConfig, + modelSwitchConfig, }; } diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx index 7645ffeb71..da248cb401 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx @@ -47,6 +47,7 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel( searchMenuConfig, networkSearchConfig, reasoningConfig, + modelSwitchConfig, } = useAIChatConfig(); useEffect(() => { @@ -73,6 +74,7 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel( chatPanelRef.current.searchMenuConfig = searchMenuConfig; chatPanelRef.current.networkSearchConfig = networkSearchConfig; chatPanelRef.current.reasoningConfig = reasoningConfig; + chatPanelRef.current.modelSwitchConfig = modelSwitchConfig; chatPanelRef.current.extensions = editor.host.std .get(ViewExtensionManagerIdentifier) .get('preview-page'); @@ -107,6 +109,7 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel( networkSearchConfig, searchMenuConfig, reasoningConfig, + modelSwitchConfig, ]); return
; diff --git a/packages/frontend/core/src/modules/ai-button/index.ts b/packages/frontend/core/src/modules/ai-button/index.ts index 576892059a..1266fd028f 100644 --- a/packages/frontend/core/src/modules/ai-button/index.ts +++ b/packages/frontend/core/src/modules/ai-button/index.ts @@ -7,6 +7,7 @@ import { FeatureFlagService } from '../feature-flag'; import { GlobalStateService } from '../storage'; import { AIButtonProvider } from './provider/ai-button'; import { AIButtonService } from './services/ai-button'; +import { AIModelSwitchService } from './services/model-switch'; import { AINetworkSearchService } from './services/network-search'; import { AIReasoningService } from './services/reasoning'; @@ -26,3 +27,7 @@ export function configureAINetworkSearchModule(framework: Framework) { export function configureAIReasoningModule(framework: Framework) { framework.service(AIReasoningService, [GlobalStateService]); } + +export function configureAIModelSwitchModule(framework: Framework) { + framework.service(AIModelSwitchService, [FeatureFlagService]); +} diff --git a/packages/frontend/core/src/modules/ai-button/services/model-switch.ts b/packages/frontend/core/src/modules/ai-button/services/model-switch.ts new file mode 100644 index 0000000000..d2052c2369 --- /dev/null +++ b/packages/frontend/core/src/modules/ai-button/services/model-switch.ts @@ -0,0 +1,26 @@ +import { + createSignalFromObservable, + type Signal, +} from '@blocksuite/affine/shared/utils'; +import { Service } from '@toeverything/infra'; + +import type { FeatureFlagService } from '../../feature-flag'; + +export class AIModelSwitchService extends Service { + constructor(private readonly featureFlagService: FeatureFlagService) { + super(); + + const { signal: visible, cleanup: visibleCleanup } = + createSignalFromObservable( + this._visible$, + undefined + ); + this.visible = visible; + this.disposables.push(visibleCleanup); + } + + visible: Signal; + + private readonly _visible$ = + this.featureFlagService.flags.enable_ai_model_switch.$; +} diff --git a/packages/frontend/core/src/modules/feature-flag/constant.ts b/packages/frontend/core/src/modules/feature-flag/constant.ts index 9cd6e9ac73..76a2325d38 100644 --- a/packages/frontend/core/src/modules/feature-flag/constant.ts +++ b/packages/frontend/core/src/modules/feature-flag/constant.ts @@ -26,6 +26,15 @@ export const AFFINE_FLAGS = { configurable: false, defaultState: true, }, + enable_ai_model_switch: { + category: 'affine', + displayName: + 'com.affine.settings.workspace.experimental-features.enable-ai-model-switch.name', + description: + 'com.affine.settings.workspace.experimental-features.enable-ai-model-switch.description', + configurable: isCanaryBuild, + defaultState: isCanaryBuild, + }, enable_edgeless_text: { category: 'blocksuite', bsFlag: 'enable_edgeless_text', diff --git a/packages/frontend/core/src/modules/index.ts b/packages/frontend/core/src/modules/index.ts index 21be3365be..741eb51569 100644 --- a/packages/frontend/core/src/modules/index.ts +++ b/packages/frontend/core/src/modules/index.ts @@ -3,6 +3,7 @@ import { type Framework } from '@toeverything/infra'; import { configureAIButtonModule, + configureAIModelSwitchModule, configureAINetworkSearchModule, configureAIReasoningModule, } from './ai-button'; @@ -105,6 +106,7 @@ export function configureCommonModules(framework: Framework) { configureCommonGlobalStorageImpls(framework); configureAINetworkSearchModule(framework); configureAIReasoningModule(framework); + configureAIModelSwitchModule(framework); configureAIButtonModule(framework); configureTemplateDocModule(framework); configureBlobManagementModule(framework); diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index abe4af0acd..69e19dcfdb 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -5626,6 +5626,14 @@ export function useAFFiNEI18N(): { * `Enable or disable AI Network Search feature.` */ ["com.affine.settings.workspace.experimental-features.enable-ai-network-search.description"](): string; + /** + * `Enable AI Model Switch` + */ + ["com.affine.settings.workspace.experimental-features.enable-ai-model-switch.name"](): string; + /** + * `Enable or disable AI model switch feature.` + */ + ["com.affine.settings.workspace.experimental-features.enable-ai-model-switch.description"](): string; /** * `Database Full Width` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 4e5f9153e4..310e6e5200 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1405,6 +1405,8 @@ "com.affine.settings.workspace.experimental-features.enable-ai.description": "Enable or disable ALL AI features.", "com.affine.settings.workspace.experimental-features.enable-ai-network-search.name": "Enable AI Network Search", "com.affine.settings.workspace.experimental-features.enable-ai-network-search.description": "Enable or disable AI Network Search feature.", + "com.affine.settings.workspace.experimental-features.enable-ai-model-switch.name": "Enable AI Model Switch", + "com.affine.settings.workspace.experimental-features.enable-ai-model-switch.description": "Enable or disable AI model switch feature.", "com.affine.settings.workspace.experimental-features.enable-database-full-width.name": "Database Full Width", "com.affine.settings.workspace.experimental-features.enable-database-full-width.description": "The database will be displayed in full-width mode.", "com.affine.settings.workspace.experimental-features.enable-database-attachment-note.name": "Database Attachment Note",