Files
vscode/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts
2026-03-19 20:16:49 -05:00

271 lines
15 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import './media/chatSetup.css';
import { $ } from '../../../../../base/browser/dom.js';
import { IButton } from '../../../../../base/browser/ui/button/button.js';
import { Dialog, DialogContentsAlignment } from '../../../../../base/browser/ui/dialog/dialog.js';
import { coalesce } from '../../../../../base/common/arrays.js';
import { Codicon } from '../../../../../base/common/codicons.js';
import { ThemeIcon } from '../../../../../base/common/themables.js';
import { toErrorMessage } from '../../../../../base/common/errorMessage.js';
import { MarkdownString } from '../../../../../base/common/htmlContent.js';
import { Lazy } from '../../../../../base/common/lazy.js';
import { DisposableStore } from '../../../../../base/common/lifecycle.js';
import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js';
import { localize } from '../../../../../nls.js';
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
import { createWorkbenchDialogOptions } from '../../../../browser/parts/dialogs/dialog.js';
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js';
import { ILayoutService } from '../../../../../platform/layout/browser/layoutService.js';
import { ILogService } from '../../../../../platform/log/common/log.js';
import product from '../../../../../platform/product/common/product.js';
import { ITelemetryService, TelemetryLevel } from '../../../../../platform/telemetry/common/telemetry.js';
import { IWorkspaceTrustRequestService } from '../../../../../platform/workspace/common/workspaceTrust.js';
import { IWorkbenchLayoutService } from '../../../../services/layout/browser/layoutService.js';
import { ChatEntitlement, ChatEntitlementContext, ChatEntitlementService, IChatEntitlementService, isProUser } from '../../../../services/chat/common/chatEntitlementService.js';
import { IChatWidgetService } from '../chat.js';
import { ChatSetupController } from './chatSetupController.js';
import { IChatSetupResult, ChatSetupAnonymous, InstallChatEvent, InstallChatClassification, ChatSetupStrategy, ChatSetupResultValue } from './chatSetup.js';
import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js';
import { IHostService } from '../../../../services/host/browser/host.js';
const defaultChat = {
publicCodeMatchesUrl: product.defaultChatAgent?.publicCodeMatchesUrl ?? '',
provider: product.defaultChatAgent?.provider ?? { default: { id: '', name: '' }, enterprise: { id: '', name: '' }, apple: { id: '', name: '' }, google: { id: '', name: '' } },
manageSettingsUrl: product.defaultChatAgent?.manageSettingsUrl ?? '',
completionsRefreshTokenCommand: product.defaultChatAgent?.completionsRefreshTokenCommand ?? '',
chatRefreshTokenCommand: product.defaultChatAgent?.chatRefreshTokenCommand ?? '',
termsStatementUrl: product.defaultChatAgent?.termsStatementUrl ?? '',
privacyStatementUrl: product.defaultChatAgent?.privacyStatementUrl ?? ''
};
export class ChatSetup {
private static instance: ChatSetup | undefined = undefined;
static getInstance(instantiationService: IInstantiationService, context: ChatEntitlementContext, controller: Lazy<ChatSetupController>): ChatSetup {
let instance = ChatSetup.instance;
if (!instance) {
instance = ChatSetup.instance = instantiationService.invokeFunction(accessor => {
return new ChatSetup(context, controller, accessor.get(ITelemetryService), accessor.get(IWorkbenchLayoutService), accessor.get(IKeybindingService), accessor.get(IChatEntitlementService) as ChatEntitlementService, accessor.get(ILogService), accessor.get(IChatWidgetService), accessor.get(IWorkspaceTrustRequestService), accessor.get(IMarkdownRendererService), accessor.get(IDefaultAccountService), accessor.get(IHostService));
});
}
return instance;
}
private pendingRun: Promise<IChatSetupResult> | undefined = undefined;
private skipDialogOnce = false;
private constructor(
private readonly context: ChatEntitlementContext,
private readonly controller: Lazy<ChatSetupController>,
@ITelemetryService private readonly telemetryService: ITelemetryService,
@ILayoutService private readonly layoutService: IWorkbenchLayoutService,
@IKeybindingService private readonly keybindingService: IKeybindingService,
@IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService,
@ILogService private readonly logService: ILogService,
@IChatWidgetService private readonly widgetService: IChatWidgetService,
@IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService,
@IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService,
@IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService,
@IHostService private readonly hostService: IHostService,
) { }
skipDialog(): void {
this.skipDialogOnce = true;
}
async run(options?: { disableChatViewReveal?: boolean; forceSignInDialog?: boolean; additionalScopes?: readonly string[]; forceAnonymous?: ChatSetupAnonymous; dialogIcon?: ThemeIcon; dialogTitle?: string }): Promise<IChatSetupResult> {
if (this.pendingRun) {
return this.pendingRun;
}
this.pendingRun = this.doRun(options);
try {
return await this.pendingRun;
} finally {
this.pendingRun = undefined;
}
}
private async doRun(options?: { disableChatViewReveal?: boolean; forceSignInDialog?: boolean; additionalScopes?: readonly string[]; forceAnonymous?: ChatSetupAnonymous; dialogIcon?: ThemeIcon; dialogTitle?: string }): Promise<IChatSetupResult> {
this.context.update({ later: false });
const dialogSkipped = this.skipDialogOnce;
this.skipDialogOnce = false;
const trusted = await this.workspaceTrustRequestService.requestWorkspaceTrust({
message: localize('chatWorkspaceTrust', "AI features are currently only supported in trusted workspaces.")
});
if (!trusted) {
this.context.update({ later: true });
this.telemetryService.publicLog2<InstallChatEvent, InstallChatClassification>('commandCenter.chatInstall', { installResult: 'failedNotTrusted', installDuration: 0, signUpErrorCode: undefined, provider: undefined });
return { dialogSkipped, success: undefined /* canceled */ };
}
let setupStrategy: ChatSetupStrategy;
if (!options?.forceSignInDialog && (dialogSkipped || isProUser(this.chatEntitlementService.entitlement) || this.chatEntitlementService.entitlement === ChatEntitlement.Free)) {
setupStrategy = ChatSetupStrategy.DefaultSetup; // existing pro/free users setup without a dialog
} else if (options?.forceAnonymous === ChatSetupAnonymous.EnabledWithoutDialog) {
setupStrategy = ChatSetupStrategy.DefaultSetup; // anonymous setup without a dialog
} else {
setupStrategy = await this.showDialog(options);
}
if (setupStrategy === ChatSetupStrategy.DefaultSetup && this.defaultAccountService.getDefaultAccountAuthenticationProvider().enterprise) {
setupStrategy = ChatSetupStrategy.SetupWithEnterpriseProvider; // users with a configured provider go through provider setup
}
if (setupStrategy !== ChatSetupStrategy.Canceled && !options?.disableChatViewReveal) {
// Show the chat view now to better indicate progress
// while installing the extension or returning from sign in
this.widgetService.revealWidget();
}
let success: ChatSetupResultValue = undefined;
try {
switch (setupStrategy) {
case ChatSetupStrategy.SetupWithEnterpriseProvider:
success = await this.controller.value.setupWithProvider({ useEnterpriseProvider: true, useSocialProvider: undefined, additionalScopes: options?.additionalScopes, forceAnonymous: options?.forceAnonymous });
break;
case ChatSetupStrategy.SetupWithoutEnterpriseProvider:
success = await this.controller.value.setupWithProvider({ useEnterpriseProvider: false, useSocialProvider: undefined, additionalScopes: options?.additionalScopes, forceAnonymous: options?.forceAnonymous });
break;
case ChatSetupStrategy.SetupWithAppleProvider:
success = await this.controller.value.setupWithProvider({ useEnterpriseProvider: false, useSocialProvider: 'apple', additionalScopes: options?.additionalScopes, forceAnonymous: options?.forceAnonymous });
break;
case ChatSetupStrategy.SetupWithGoogleProvider:
success = await this.controller.value.setupWithProvider({ useEnterpriseProvider: false, useSocialProvider: 'google', additionalScopes: options?.additionalScopes, forceAnonymous: options?.forceAnonymous });
break;
case ChatSetupStrategy.DefaultSetup:
success = await this.controller.value.setup({ ...options, forceAnonymous: options?.forceAnonymous });
break;
case ChatSetupStrategy.Canceled:
this.context.update({ later: true });
this.telemetryService.publicLog2<InstallChatEvent, InstallChatClassification>('commandCenter.chatInstall', { installResult: 'failedMaybeLater', installDuration: 0, signUpErrorCode: undefined, provider: undefined });
break;
}
} catch (error) {
this.logService.error(`[chat setup] Error during setup: ${toErrorMessage(error)}`);
success = false;
}
return { success, dialogSkipped };
}
private async showDialog(options?: { forceSignInDialog?: boolean; forceAnonymous?: ChatSetupAnonymous; dialogIcon?: ThemeIcon; dialogTitle?: string }): Promise<ChatSetupStrategy> {
const disposables = new DisposableStore();
const buttons = this.getButtons(options);
const dialog = disposables.add(new Dialog(
this.layoutService.activeContainer,
this.getDialogTitle(options),
buttons.map(button => button[0]),
createWorkbenchDialogOptions({
type: 'none',
extraClasses: ['chat-setup-dialog'],
detail: ' ', // workaround allowing us to render the message in large
icon: options?.dialogIcon ?? Codicon.copilotLarge,
alignment: DialogContentsAlignment.Vertical,
cancelId: buttons.length,
disableCloseButton: false,
renderFooter: footer => footer.appendChild(this.createDialogFooter(disposables, options)),
buttonOptions: buttons.map(button => button[2])
}, this.keybindingService, this.layoutService, this.hostService)
));
const { button } = await dialog.show();
disposables.dispose();
return buttons[button]?.[1] ?? ChatSetupStrategy.Canceled;
}
private getButtons(options?: { forceSignInDialog?: boolean; forceAnonymous?: ChatSetupAnonymous }): Array<[string, ChatSetupStrategy, { styleButton?: (button: IButton) => void } | undefined]> {
type ContinueWithButton = [string, ChatSetupStrategy, { styleButton?: (button: IButton) => void } | undefined];
const styleButton = (...classes: string[]) => ({ styleButton: (button: IButton) => button.element.classList.add(...classes) });
let buttons: Array<ContinueWithButton>;
if (!options?.forceAnonymous && (this.context.state.entitlement === ChatEntitlement.Unknown || options?.forceSignInDialog)) {
const defaultProviderButton: ContinueWithButton = [localize('continueWith', "Continue with {0}", defaultChat.provider.default.name), ChatSetupStrategy.SetupWithoutEnterpriseProvider, styleButton('continue-button', 'default')];
const defaultProviderLink: ContinueWithButton = [defaultProviderButton[0], defaultProviderButton[1], styleButton('link-button')];
const enterpriseProviderButton: ContinueWithButton = [localize('continueWith', "Continue with {0}", defaultChat.provider.enterprise.name), ChatSetupStrategy.SetupWithEnterpriseProvider, styleButton('continue-button', 'default')];
const enterpriseProviderLink: ContinueWithButton = [enterpriseProviderButton[0], enterpriseProviderButton[1], styleButton('link-button')];
const googleProviderButton: ContinueWithButton = [localize('continueWith', "Continue with {0}", defaultChat.provider.google.name), ChatSetupStrategy.SetupWithGoogleProvider, styleButton('continue-button', 'google')];
const appleProviderButton: ContinueWithButton = [localize('continueWith', "Continue with {0}", defaultChat.provider.apple.name), ChatSetupStrategy.SetupWithAppleProvider, styleButton('continue-button', 'apple')];
if (!this.defaultAccountService.getDefaultAccountAuthenticationProvider().enterprise) {
buttons = coalesce([
defaultProviderButton,
googleProviderButton,
appleProviderButton,
enterpriseProviderLink
]);
} else {
buttons = coalesce([
enterpriseProviderButton,
googleProviderButton,
appleProviderButton,
defaultProviderLink
]);
}
} else {
buttons = [[localize('setupAIButton', "Use AI Features"), ChatSetupStrategy.DefaultSetup, undefined]];
}
return buttons;
}
private getDialogTitle(options?: { forceSignInDialog?: boolean; forceAnonymous?: ChatSetupAnonymous; dialogTitle?: string }): string {
if (options?.dialogTitle) {
return options.dialogTitle;
}
if (this.chatEntitlementService.anonymous) {
if (options?.forceAnonymous) {
return localize('startUsing', "Start using AI Features");
} else {
return localize('enableMore', "Enable more AI features");
}
}
if (this.context.state.entitlement === ChatEntitlement.Unknown || options?.forceSignInDialog) {
return localize('signIn', "Sign in to use AI Features");
}
return localize('startUsing', "Start using AI Features");
}
private createDialogFooter(disposables: DisposableStore, options?: { forceAnonymous?: ChatSetupAnonymous }): HTMLElement {
const element = $('.chat-setup-dialog-footer');
let footer: string;
if (options?.forceAnonymous || this.telemetryService.telemetryLevel === TelemetryLevel.NONE) {
footer = localize({ key: 'settingsAnonymous', comment: ['{Locked="["}', '{Locked="]({1})"}', '{Locked="]({2})"}'] }, "By continuing, you agree to {0}'s [Terms]({1}) and [Privacy Statement]({2}).", defaultChat.provider.default.name, defaultChat.termsStatementUrl, defaultChat.privacyStatementUrl);
} else {
footer = localize({ key: 'settings', comment: ['{Locked="["}', '{Locked="]({1})"}', '{Locked="]({2})"}', '{Locked="]({4})"}', '{Locked="]({5})"}'] }, "By continuing, you agree to {0}'s [Terms]({1}) and [Privacy Statement]({2}). {3} Copilot may show [public code]({4}) suggestions and use your data to improve the product. You can change these [settings]({5}) anytime.", defaultChat.provider.default.name, defaultChat.termsStatementUrl, defaultChat.privacyStatementUrl, defaultChat.provider.default.name, defaultChat.publicCodeMatchesUrl, defaultChat.manageSettingsUrl);
}
element.appendChild($('p', undefined, disposables.add(this.markdownRendererService.render(new MarkdownString(footer, { isTrusted: true }))).element));
return element;
}
}
//#endregion
export function refreshTokens(commandService: ICommandService): void {
// ugly, but we need to signal to the extension that entitlements changed
commandService.executeCommand(defaultChat.completionsRefreshTokenCommand);
commandService.executeCommand(defaultChat.chatRefreshTokenCommand);
}