feat(core): handle AI subscription for pro models (#13682)

<img width="576" height="251" alt="截屏2025-09-30 14 55 20"
src="https://github.com/user-attachments/assets/947a4ab3-8b34-434d-94a6-afb5dad3d32c"
/>


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

- **New Features**
- Added “Subscribe to AI” action across chat experiences (panel,
content, composer, input, playground, peek view) that launches an in-app
checkout flow.
- Chat content now refreshes subscription status when opened; desktop
chat pages wire the subscription action for seamless checkout.

- **Style**
  - Polished hover state for the subscription icon in chat preferences.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Wu Yue
2025-09-30 18:47:59 +08:00
committed by GitHub
parent 4b3ebd899b
commit 03ef4625bc
11 changed files with 125 additions and 3 deletions

View File

@@ -137,6 +137,9 @@ export class ChatPanel extends SignalWatcher(
@property({ attribute: false })
accessor aiModelService!: AIModelService;
@property({ attribute: false })
accessor onAISubscribe!: () => Promise<void>;
@state()
accessor session: CopilotChatHistoryFragment | null | undefined;
@@ -462,6 +465,7 @@ export class ChatPanel extends SignalWatcher(
.peekViewService=${this.peekViewService}
.subscriptionService=${this.subscriptionService}
.aiModelService=${this.aiModelService}
.onAISubscribe=${this.onAISubscribe}
.onEmbeddingProgressChange=${this.onEmbeddingProgressChange}
.onContextChange=${this.onContextChange}
.width=${this.sidebarWidth}

View File

@@ -149,6 +149,9 @@ export class AIChatComposer extends SignalWatcher(
@property({ attribute: false })
accessor aiModelService!: AIModelService;
@property({ attribute: false })
accessor onAISubscribe!: () => Promise<void>;
@state()
accessor chips: ChatChip[] = [];
@@ -200,6 +203,7 @@ export class AIChatComposer extends SignalWatcher(
.notificationService=${this.notificationService}
.subscriptionService=${this.subscriptionService}
.aiModelService=${this.aiModelService}
.onAISubscribe=${this.onAISubscribe}
.portalContainer=${this.portalContainer}
.onChatSuccess=${this.onChatSuccess}
.trackOptions=${this.trackOptions}

View File

@@ -192,6 +192,9 @@ export class AIChatContent extends SignalWatcher(
@property({ attribute: false })
accessor subscriptionService!: SubscriptionService;
@property({ attribute: false })
accessor onAISubscribe!: () => Promise<void>;
@state()
accessor chatContextValue: ChatContextValue = DEFAULT_CHAT_CONTEXT_VALUE;
@@ -381,6 +384,9 @@ export class AIChatContent extends SignalWatcher(
.catch(console.error);
}
// revalidate subscription to get the latest status
this.subscriptionService.subscription.revalidate();
this._disposables.add(
AIProvider.slots.actions.subscribe(({ event }) => {
const { status } = this.chatContextValue;
@@ -472,6 +478,7 @@ export class AIChatContent extends SignalWatcher(
.aiToolsConfigService=${this.aiToolsConfigService}
.subscriptionService=${this.subscriptionService}
.aiModelService=${this.aiModelService}
.onAISubscribe=${this.onAISubscribe}
.trackOptions=${{
where: 'chat-panel',
control: 'chat-send',

View File

@@ -377,6 +377,9 @@ export class AIChatInput extends SignalWatcher(
@property({ attribute: false })
accessor aiModelService!: AIModelService;
@property({ attribute: false })
accessor onAISubscribe!: () => Promise<void>;
@property({ attribute: false })
accessor isRootSession: boolean = true;
@@ -534,6 +537,7 @@ export class AIChatInput extends SignalWatcher(
.notificationService=${this.notificationService}
.subscriptionService=${this.subscriptionService}
.aiModelService=${this.aiModelService}
.onAISubscribe=${this.onAISubscribe}
></chat-input-preference>
${status === 'transmitting' || status === 'loading'
? html`<button

View File

@@ -72,6 +72,9 @@ export class ChatInputPreference extends SignalWatcher(
.ai-model-prefix svg {
color: ${unsafeCSSVarV2('icon/activated')};
}
.ai-model-postfix svg:hover {
color: ${unsafeCSSVarV2('icon/activated')};
}
.ai-model-version {
font-size: 12px;
color: ${unsafeCSSVarV2('text/tertiary')};
@@ -119,6 +122,9 @@ export class ChatInputPreference extends SignalWatcher(
@property({ attribute: false })
accessor aiModelService!: AIModelService;
@property({ attribute: false })
accessor onAISubscribe!: () => Promise<void>;
model = computed(() => {
const modelId = this.aiModelService.modelId.value;
const activeModel = this.aiModelService.models.value.find(
@@ -161,7 +167,7 @@ export class ChatInputPreference extends SignalWatcher(
</div>
`,
postfix: html`
<div>
<div class="ai-model-postfix" @click=${this.onAISubscribe}>
${model.isPro && !isSubscribed ? LockIcon() : undefined}
</div>
`,

View File

@@ -182,6 +182,9 @@ export class PlaygroundChat extends SignalWatcher(
@property({ attribute: false })
accessor aiToolsConfigService!: AIToolsConfigService;
@property({ attribute: false })
accessor onAISubscribe: (() => Promise<void>) | undefined;
@property({ attribute: false })
accessor addChat!: () => Promise<void>;
@@ -374,6 +377,7 @@ export class PlaygroundChat extends SignalWatcher(
.aiToolsConfigService=${this.aiToolsConfigService}
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
.affineFeatureFlagService=${this.affineFeatureFlagService}
.onAISubscribe=${this.onAISubscribe}
></ai-chat-composer>
</div>`;
}

View File

@@ -2,6 +2,8 @@ import type {
AIDraftService,
AIToolsConfigService,
} from '@affine/core/modules/ai-button';
import type { AIModelService } from '@affine/core/modules/ai-button/services/models';
import type { SubscriptionService } from '@affine/core/modules/cloud';
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type {
@@ -622,6 +624,9 @@ export class AIChatBlockPeekView extends LitElement {
}}
.portalContainer=${this.parentElement}
.reasoningConfig=${this.reasoningConfig}
.subscriptionService=${this.subscriptionService}
.aiModelService=${this.aiModelService}
.onAISubscribe=${this.onAISubscribe}
></ai-chat-composer>
</div> `;
}
@@ -659,6 +664,15 @@ export class AIChatBlockPeekView extends LitElement {
@property({ attribute: false })
accessor aiToolsConfigService!: AIToolsConfigService;
@property({ attribute: false })
accessor aiModelService!: AIModelService;
@property({ attribute: false })
accessor subscriptionService!: SubscriptionService;
@property({ attribute: false })
accessor onAISubscribe!: () => Promise<void>;
@state()
accessor _historyMessages: ChatMessage[] = [];
@@ -697,7 +711,10 @@ export const AIChatBlockPeekViewTemplate = (
affineFeatureFlagService: FeatureFlagService,
affineWorkspaceDialogService: WorkspaceDialogService,
aiDraftService: AIDraftService,
aiToolsConfigService: AIToolsConfigService
aiToolsConfigService: AIToolsConfigService,
subscriptionService: SubscriptionService,
aiModelService: AIModelService,
onAISubscribe: (() => Promise<void>) | undefined
) => {
return html`<ai-chat-block-peek-view
.blockModel=${blockModel}
@@ -710,5 +727,8 @@ export const AIChatBlockPeekViewTemplate = (
.affineWorkspaceDialogService=${affineWorkspaceDialogService}
.aiDraftService=${aiDraftService}
.aiToolsConfigService=${aiToolsConfigService}
.subscriptionService=${subscriptionService}
.aiModelService=${aiModelService}
.onAISubscribe=${onAISubscribe}
></ai-chat-block-peek-view>`;
};

View File

@@ -0,0 +1,52 @@
import { generateSubscriptionCallbackLink } from '@affine/core/components/hooks/affine/use-subscription-notify';
import { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
import { UrlService } from '@affine/core/modules/url';
import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
import { useFramework } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { useCallback } from 'react';
/**
* Hook to handle AI subscription checkout
* @returns A function that initiates the AI subscription checkout process
*/
export const useAISubscribe = () => {
const framework = useFramework();
const handleAISubscribe = useCallback(async () => {
try {
const authService = framework.get(AuthService);
const subscriptionService = framework.get(SubscriptionService);
const urlService = framework.get(UrlService);
const account = authService.session.account$.value;
if (!account) {
return;
}
const idempotencyKey = nanoid();
const checkoutOptions = {
recurring: SubscriptionRecurring.Yearly,
plan: SubscriptionPlan.AI,
variant: null,
coupon: null,
successCallbackLink: generateSubscriptionCallbackLink(
account,
SubscriptionPlan.AI,
SubscriptionRecurring.Yearly
),
};
const session = await subscriptionService.createCheckoutSession({
idempotencyKey,
...checkoutOptions,
});
urlService.openExternal(session);
} catch (error) {
console.error(error);
}
}, [framework]);
return handleAISubscribe;
};

View File

@@ -11,6 +11,7 @@ import { getViewManager } from '@affine/core/blocksuite/manager/view';
import { NotificationServiceImpl } from '@affine/core/blocksuite/view-extensions/editor-view/notification-service';
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
import { useAISpecs } from '@affine/core/components/hooks/affine/use-ai-specs';
import { useAISubscribe } from '@affine/core/components/hooks/affine/use-ai-subscribe';
import {
AIDraftService,
AIToolsConfigService,
@@ -197,6 +198,7 @@ export const Component = () => {
const confirmModal = useConfirmModal();
const specs = useAISpecs();
const mockStd = useMockStd();
const handleAISubscribe = useAISubscribe();
// init or update ai-chat-content
useEffect(() => {
@@ -233,6 +235,8 @@ export const Component = () => {
content.aiToolsConfigService = framework.get(AIToolsConfigService);
content.subscriptionService = framework.get(SubscriptionService);
content.aiModelService = framework.get(AIModelService);
content.onAISubscribe = handleAISubscribe;
content.createSession = createSession;
content.onOpenDoc = onOpenDoc;
@@ -260,6 +264,7 @@ export const Component = () => {
onContextChange,
specs,
onOpenDoc,
handleAISubscribe,
]);
// init or update header ai-chat-toolbar

View File

@@ -4,6 +4,7 @@ import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-
import { NotificationServiceImpl } from '@affine/core/blocksuite/view-extensions/editor-view/notification-service';
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
import { useAISpecs } from '@affine/core/components/hooks/affine/use-ai-specs';
import { useAISubscribe } from '@affine/core/components/hooks/affine/use-ai-subscribe';
import {
AIDraftService,
AIToolsConfigService,
@@ -63,6 +64,7 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
} = useAIChatConfig();
const confirmModal = useConfirmModal();
const specs = useAISpecs();
const handleAISubscribe = useAISubscribe();
useEffect(() => {
if (!editor || !editor.host) return;
@@ -109,6 +111,7 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
chatPanelRef.current.subscriptionService =
framework.get(SubscriptionService);
chatPanelRef.current.aiModelService = framework.get(AIModelService);
chatPanelRef.current.onAISubscribe = handleAISubscribe;
containerRef.current?.append(chatPanelRef.current);
} else {
@@ -141,6 +144,7 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
playgroundConfig,
confirmModal,
specs,
handleAISubscribe,
]);
const [autoResized, setAutoResized] = useState(false);

View File

@@ -2,10 +2,13 @@ import { toReactNode } from '@affine/component';
import { AIChatBlockPeekViewTemplate } from '@affine/core/blocksuite/ai';
import type { AIChatBlockModel } from '@affine/core/blocksuite/ai/blocks/ai-chat-block/model/ai-chat-model';
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
import { useAISubscribe } from '@affine/core/components/hooks/affine/use-ai-subscribe';
import {
AIDraftService,
AIToolsConfigService,
} from '@affine/core/modules/ai-button';
import { AIModelService } from '@affine/core/modules/ai-button/services/models';
import { SubscriptionService } from '@affine/core/modules/cloud';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { EditorHost } from '@blocksuite/affine/std';
@@ -33,6 +36,9 @@ export const AIChatBlockPeekView = ({
const affineWorkspaceDialogService = framework.get(WorkspaceDialogService);
const aiDraftService = framework.get(AIDraftService);
const aiToolsConfigService = framework.get(AIToolsConfigService);
const subscriptionService = framework.get(SubscriptionService);
const aiModelService = framework.get(AIModelService);
const handleAISubscribe = useAISubscribe();
return useMemo(() => {
const template = AIChatBlockPeekViewTemplate(
@@ -45,7 +51,10 @@ export const AIChatBlockPeekView = ({
affineFeatureFlagService,
affineWorkspaceDialogService,
aiDraftService,
aiToolsConfigService
aiToolsConfigService,
subscriptionService,
aiModelService,
handleAISubscribe
);
return toReactNode(template);
}, [
@@ -59,5 +68,8 @@ export const AIChatBlockPeekView = ({
affineWorkspaceDialogService,
aiDraftService,
aiToolsConfigService,
subscriptionService,
aiModelService,
handleAISubscribe,
]);
};