feat(core): support gemini model switch in ai (#13631)

<img width="757" height="447" alt="截屏2025-09-22 17 49 34"
src="https://github.com/user-attachments/assets/bab96f45-112e-4d74-bc38-54429d8a54ab"
/>


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

- New Features
- Subscription-aware AI model picker in chat: browse models with version
and category, see active selection, switch models, and receive
notifications when choosing pro models without a subscription.
Selections persist across sessions.
- Central AI model service wired into chat UI for consistent model
selection and availability.

- Changes
- Streamlined AI model availability: reduced to a curated set for a more
focused experience.
  - Context menu buttons can display supplemental info next to labels.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Wu Yue
2025-09-22 21:25:11 +08:00
committed by GitHub
parent da3e3eb3fa
commit b25759c264
12 changed files with 281 additions and 50 deletions
@@ -8,12 +8,14 @@ export {
import type { Framework } from '@toeverything/infra';
import { GraphQLService, ServerScope, SubscriptionService } from '../cloud';
import { FeatureFlagService } from '../feature-flag';
import { CacheStorage, GlobalStateService } from '../storage';
import { WorkspaceScope } from '../workspace';
import { AIButtonProvider } from './provider/ai-button';
import { AIButtonService } from './services/ai-button';
import { AIDraftService } from './services/ai-draft';
import { AIModelService } from './services/models';
import { AINetworkSearchService } from './services/network-search';
import { AIPlaygroundService } from './services/playground';
import { AIReasoningService } from './services/reasoning';
@@ -49,3 +51,13 @@ export function configureAIDraftModule(framework: Framework) {
export function configureAIToolsConfigModule(framework: Framework) {
framework.service(AIToolsConfigService, [GlobalStateService]);
}
export function configureAIModelModule(framework: Framework) {
framework
.scope(ServerScope)
.service(AIModelService, [
GlobalStateService,
GraphQLService,
SubscriptionService,
]);
}
@@ -0,0 +1,112 @@
import { getPromptModelsQuery, SubscriptionStatus } from '@affine/graphql';
import {
createSignalFromObservable,
type Signal,
} from '@blocksuite/affine/shared/utils';
import { signal } from '@preact/signals-core';
import { LiveData, Service } from '@toeverything/infra';
import type { GraphQLService, SubscriptionService } from '../../cloud';
import type { GlobalStateService } from '../../storage';
const AI_MODEL_ID_KEY = 'AIModelId';
export interface AIModel {
name: string;
id: string;
version: string;
category: string;
isPro: boolean;
isDefault: boolean;
}
export class AIModelService extends Service {
modelId: Signal<string | undefined>;
models: Signal<AIModel[]> = signal([]);
private readonly modelId$ = LiveData.from(
this.globalStateService.globalState.watch<string>(AI_MODEL_ID_KEY),
undefined
);
constructor(
private readonly globalStateService: GlobalStateService,
private readonly gqlService: GraphQLService,
private readonly subscriptionService: SubscriptionService
) {
super();
const { signal: modelId, cleanup } = createSignalFromObservable<
string | undefined
>(this.modelId$, undefined);
this.modelId = modelId;
this.disposables.push(cleanup);
this.init().catch(err => {
console.error(err);
});
}
resetModel = () => {
this.globalStateService.globalState.set(AI_MODEL_ID_KEY, undefined);
};
setModel = (modelId: string) => {
const isSubscribed =
this.subscriptionService.subscription.ai$.value?.status ===
SubscriptionStatus.Active;
const model = this.models.value.find(model => model.id === modelId);
if (!isSubscribed && model?.isPro) {
return;
}
this.globalStateService.globalState.set(AI_MODEL_ID_KEY, modelId);
};
private readonly init = async () => {
await this.initModels();
// subscribe to ai purchase status
const sub = this.subscriptionService.subscription.ai$.subscribe(
subscription => {
const isSubscribed = subscription?.status === SubscriptionStatus.Active;
const model = this.models.value.find(
model => model.id === this.modelId.value
);
if (!isSubscribed && model?.isPro) {
this.resetModel();
}
}
);
this.disposables.push(() => sub.unsubscribe());
};
private readonly initModels = async (prompt?: string) => {
const promptName = prompt || 'Chat With AFFiNE AI';
const models = await this.getModelsByPrompt(promptName);
if (models) {
const { defaultModel, optionalModels, proModels } = models;
this.models.value = optionalModels.map(model => {
const [category] = model.name.split(' ');
const version = model.name.slice(category.length + 1);
return {
name: model.name,
id: model.id,
version,
category,
isPro: proModels.some(proModel => proModel.id === model.id),
isDefault: model.id === defaultModel,
};
});
}
};
private readonly getModelsByPrompt = async (promptName: string) => {
return this.gqlService
.gql({
query: getPromptModelsQuery,
variables: { promptName },
})
.then(res => res.currentUser?.copilot?.models);
};
}
@@ -4,6 +4,7 @@ import { type Framework } from '@toeverything/infra';
import {
configureAIButtonModule,
configureAIDraftModule,
configureAIModelModule,
configureAINetworkSearchModule,
configureAIPlaygroundModule,
configureAIReasoningModule,
@@ -117,6 +118,7 @@ export function configureCommonModules(framework: Framework) {
configureAIButtonModule(framework);
configureAIDraftModule(framework);
configureAIToolsConfigModule(framework);
configureAIModelModule(framework);
configureTemplateDocModule(framework);
configureBlobManagementModule(framework);
configureMediaModule(framework);