Path: blob/main/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupController.ts
5253 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { toErrorMessage } from '../../../../../base/common/errorMessage.js';6import { isCancellationError } from '../../../../../base/common/errors.js';7import { Emitter } from '../../../../../base/common/event.js';8import { Disposable } from '../../../../../base/common/lifecycle.js';9import Severity from '../../../../../base/common/severity.js';10import { StopWatch } from '../../../../../base/common/stopwatch.js';11import { isObject, isUndefined } from '../../../../../base/common/types.js';12import { localize } from '../../../../../nls.js';13import { ICommandService } from '../../../../../platform/commands/common/commands.js';14import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';15import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js';16import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';17import { ILogService } from '../../../../../platform/log/common/log.js';18import product from '../../../../../platform/product/common/product.js';19import { IProductService } from '../../../../../platform/product/common/productService.js';20import { IProgressService, ProgressLocation } from '../../../../../platform/progress/common/progress.js';21import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js';22import { Registry } from '../../../../../platform/registry/common/platform.js';23import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';24import { IActivityService, ProgressBadge } from '../../../../services/activity/common/activity.js';25import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js';26import { IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js';27import { ChatEntitlement, ChatEntitlementContext, ChatEntitlementRequests, isProUser } from '../../../../services/chat/common/chatEntitlementService.js';28import { CHAT_OPEN_ACTION_ID } from '../actions/chatActions.js';29import { ChatViewId, ChatViewContainerId } from '../chat.js';30import { ChatSetupAnonymous, ChatSetupStep, ChatSetupResultValue, InstallChatEvent, InstallChatClassification, refreshTokens } from './chatSetup.js';31import { IDefaultAccount } from '../../../../../base/common/defaultAccount.js';32import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js';3334const defaultChat = {35chatExtensionId: product.defaultChatAgent?.chatExtensionId ?? '',36provider: product.defaultChatAgent?.provider ?? { default: { id: '', name: '' }, enterprise: { id: '', name: '' }, apple: { id: '', name: '' }, google: { id: '', name: '' } },37providerUriSetting: product.defaultChatAgent?.providerUriSetting ?? '',38completionsAdvancedSetting: product.defaultChatAgent?.completionsAdvancedSetting ?? '',39};4041export interface IChatSetupControllerOptions {42readonly forceSignIn?: boolean;43readonly useSocialProvider?: string;44readonly useEnterpriseProvider?: boolean;45readonly additionalScopes?: readonly string[];46readonly forceAnonymous?: ChatSetupAnonymous;47}4849export class ChatSetupController extends Disposable {5051private readonly _onDidChange = this._register(new Emitter<void>());52readonly onDidChange = this._onDidChange.event;5354private _step = ChatSetupStep.Initial;55get step(): ChatSetupStep { return this._step; }5657constructor(58private readonly context: ChatEntitlementContext,59private readonly requests: ChatEntitlementRequests,60@ITelemetryService private readonly telemetryService: ITelemetryService,61@IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService,62@IProductService private readonly productService: IProductService,63@ILogService private readonly logService: ILogService,64@IProgressService private readonly progressService: IProgressService,65@IActivityService private readonly activityService: IActivityService,66@ICommandService private readonly commandService: ICommandService,67@IDialogService private readonly dialogService: IDialogService,68@IConfigurationService private readonly configurationService: IConfigurationService,69@ILifecycleService private readonly lifecycleService: ILifecycleService,70@IQuickInputService private readonly quickInputService: IQuickInputService,71@IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService,72) {73super();7475this.registerListeners();76}7778private registerListeners(): void {79this._register(this.context.onDidChange(() => this._onDidChange.fire()));80}8182private setStep(step: ChatSetupStep): void {83if (this._step === step) {84return;85}8687this._step = step;88this._onDidChange.fire();89}9091async setup(options: IChatSetupControllerOptions = {}): Promise<ChatSetupResultValue> {92const watch = new StopWatch(false);93const title = localize('setupChatProgress', "Getting chat ready...");94const badge = this.activityService.showViewContainerActivity(ChatViewContainerId, {95badge: new ProgressBadge(() => title),96});9798try {99return await this.progressService.withProgress({100location: ProgressLocation.Window,101command: CHAT_OPEN_ACTION_ID,102title,103}, () => this.doSetup(options, watch));104} finally {105badge.dispose();106}107}108109private async doSetup(options: IChatSetupControllerOptions, watch: StopWatch): Promise<ChatSetupResultValue> {110this.context.suspend(); // reduces flicker111112let success: ChatSetupResultValue = false;113try {114let entitlement: ChatEntitlement | undefined;115116let signIn: boolean;117if (options.forceSignIn) {118signIn = true; // forced to sign in119} else if (this.context.state.entitlement === ChatEntitlement.Unknown) {120if (options.forceAnonymous) {121signIn = false; // forced to anonymous without sign in122} else {123signIn = true; // sign in since we are signed out124}125} else {126signIn = false; // already signed in127}128129if (signIn) {130this.setStep(ChatSetupStep.SigningIn);131const result = await this.signIn(options);132if (!result.defaultAccount) {133this.doInstall(); // still install the extension in the background to remind the user to sign-in eventually134135const provider = options.useSocialProvider ?? (options.useEnterpriseProvider ? defaultChat.provider.enterprise.id : defaultChat.provider.default.id);136this.telemetryService.publicLog2<InstallChatEvent, InstallChatClassification>('commandCenter.chatInstall', { installResult: 'failedNotSignedIn', installDuration: watch.elapsed(), signUpErrorCode: undefined, provider });137return undefined; // treat as cancelled because signing in already triggers an error dialog138}139140entitlement = result.entitlement;141}142143// Await Install144this.setStep(ChatSetupStep.Installing);145success = await this.install(entitlement ?? this.context.state.entitlement, watch, options);146} finally {147this.setStep(ChatSetupStep.Initial);148this.context.resume();149}150151return success;152}153154private async signIn(options: IChatSetupControllerOptions): Promise<{ defaultAccount: IDefaultAccount | undefined; entitlement: ChatEntitlement | undefined }> {155let entitlements;156let defaultAccount;157try {158({ defaultAccount, entitlements } = await this.requests.signIn(options));159} catch (e) {160this.logService.error(`[chat setup] signIn: error ${e}`);161}162163if (!defaultAccount && !this.lifecycleService.willShutdown) {164const { confirmed } = await this.dialogService.confirm({165type: Severity.Error,166message: localize('unknownSignInError', "Failed to sign in to {0}. Would you like to try again?", this.defaultAccountService.getDefaultAccountAuthenticationProvider().name),167detail: localize('unknownSignInErrorDetail', "You must be signed in to use AI features."),168primaryButton: localize('retry', "Retry")169});170171if (confirmed) {172return this.signIn(options);173}174}175176return { defaultAccount, entitlement: entitlements?.entitlement };177}178179private async install(entitlement: ChatEntitlement, watch: StopWatch, options: IChatSetupControllerOptions): Promise<ChatSetupResultValue> {180const wasRunning = this.context.state.installed && !this.context.state.disabled;181let signUpResult: boolean | { errorCode: number } | undefined = undefined;182183let provider: string;184if (options.forceAnonymous && entitlement === ChatEntitlement.Unknown) {185provider = 'anonymous';186} else {187provider = options.useSocialProvider ?? (options.useEnterpriseProvider ? defaultChat.provider.enterprise.id : defaultChat.provider.default.id);188}189190try {191if (192!options.forceAnonymous && // User is not asking for anonymous access193entitlement !== ChatEntitlement.Free && // User is not signed up to Copilot Free194!isProUser(entitlement) && // User is not signed up for a Copilot subscription195entitlement !== ChatEntitlement.Unavailable // User is eligible for Copilot Free196) {197signUpResult = await this.requests.signUpFree();198199if (isUndefined(signUpResult)) {200this.telemetryService.publicLog2<InstallChatEvent, InstallChatClassification>('commandCenter.chatInstall', { installResult: 'failedNoSession', installDuration: watch.elapsed(), signUpErrorCode: undefined, provider });201return false; // unexpected202}203204if (typeof signUpResult !== 'boolean' /* error */) {205this.telemetryService.publicLog2<InstallChatEvent, InstallChatClassification>('commandCenter.chatInstall', { installResult: 'failedSignUp', installDuration: watch.elapsed(), signUpErrorCode: signUpResult.errorCode, provider });206}207}208209await this.doInstallWithRetry();210} catch (error) {211this.logService.error(`[chat setup] install: error ${error}`);212this.telemetryService.publicLog2<InstallChatEvent, InstallChatClassification>('commandCenter.chatInstall', { installResult: isCancellationError(error) ? 'cancelled' : 'failedInstall', installDuration: watch.elapsed(), signUpErrorCode: undefined, provider });213return false;214}215216if (typeof signUpResult === 'boolean' /* not an error case */ || typeof signUpResult === 'undefined' /* already signed up */) {217this.telemetryService.publicLog2<InstallChatEvent, InstallChatClassification>('commandCenter.chatInstall', { installResult: wasRunning && !signUpResult ? 'alreadyInstalled' : 'installed', installDuration: watch.elapsed(), signUpErrorCode: undefined, provider });218}219220if (wasRunning) {221// We always trigger refresh of tokens to help the user222// get out of authentication issues that can happen when223// for example the sign-up ran after the extension tried224// to use the authentication information to mint a token225refreshTokens(this.commandService);226}227228return true;229}230231private async doInstallWithRetry(): Promise<void> {232let error: Error | undefined;233try {234await this.doInstall();235} catch (e) {236this.logService.error(`[chat setup] install: error ${error}`);237error = e;238}239240if (error) {241if (!this.lifecycleService.willShutdown) {242const { confirmed } = await this.dialogService.confirm({243type: Severity.Error,244message: localize('unknownSetupError', "An error occurred while setting up chat. Would you like to try again?"),245detail: error && !isCancellationError(error) ? toErrorMessage(error) : undefined,246primaryButton: localize('retry', "Retry")247});248249if (confirmed) {250return this.doInstallWithRetry();251}252}253254throw error;255}256}257258private async doInstall(): Promise<void> {259await this.extensionsWorkbenchService.install(defaultChat.chatExtensionId, {260enable: true,261isApplicationScoped: true, // install into all profiles262isMachineScoped: false, // do not ask to sync263installEverywhere: true, // install in local and remote264installPreReleaseVersion: this.productService.quality !== 'stable'265}, ChatViewId);266}267268async setupWithProvider(options: IChatSetupControllerOptions): Promise<ChatSetupResultValue> {269const registry = Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration);270registry.registerConfiguration({271'id': 'copilot.setup',272'type': 'object',273'properties': {274[defaultChat.completionsAdvancedSetting]: {275'type': 'object',276'properties': {277'authProvider': {278'type': 'string'279}280}281},282[defaultChat.providerUriSetting]: {283'type': 'string'284}285}286});287288if (options.useEnterpriseProvider) {289const success = await this.handleEnterpriseInstance();290if (!success) {291this.telemetryService.publicLog2<InstallChatEvent, InstallChatClassification>('commandCenter.chatInstall', { installResult: 'failedEnterpriseSetup', installDuration: 0, signUpErrorCode: undefined, provider: undefined });292return success; // not properly configured, abort293}294}295296let existingAdvancedSetting = this.configurationService.inspect(defaultChat.completionsAdvancedSetting).user?.value;297if (!isObject(existingAdvancedSetting)) {298existingAdvancedSetting = {};299}300301if (options.useEnterpriseProvider) {302await this.configurationService.updateValue(`${defaultChat.completionsAdvancedSetting}`, {303...existingAdvancedSetting,304'authProvider': defaultChat.provider.enterprise.id305}, ConfigurationTarget.USER);306} else {307await this.configurationService.updateValue(`${defaultChat.completionsAdvancedSetting}`, Object.keys(existingAdvancedSetting).length > 0 ? {308...existingAdvancedSetting,309'authProvider': undefined310} : undefined, ConfigurationTarget.USER);311}312313return this.setup({ ...options, forceSignIn: true });314}315316private async handleEnterpriseInstance(): Promise<ChatSetupResultValue> {317const domainRegEx = /^[a-zA-Z\-_]+$/;318const fullUriRegEx = /^(https:\/\/)?([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.ghe\.com\/?$/;319320const uri = this.configurationService.getValue<string>(defaultChat.providerUriSetting);321if (typeof uri === 'string' && fullUriRegEx.test(uri)) {322return true; // already setup with a valid URI323}324325let isSingleWord = false;326const result = await this.quickInputService.input({327prompt: localize('enterpriseInstance', "What is your {0} instance?", defaultChat.provider.enterprise.name),328placeHolder: localize('enterpriseInstancePlaceholder', 'i.e. "octocat" or "https://octocat.ghe.com"...'),329ignoreFocusLost: true,330value: uri,331validateInput: async value => {332isSingleWord = false;333if (!value) {334return undefined;335}336337if (domainRegEx.test(value)) {338isSingleWord = true;339return {340content: localize('willResolveTo', "Will resolve to {0}", `https://${value}.ghe.com`),341severity: Severity.Info342};343} if (!fullUriRegEx.test(value)) {344return {345content: localize('invalidEnterpriseInstance', 'You must enter a valid {0} instance (i.e. "octocat" or "https://octocat.ghe.com")', defaultChat.provider.enterprise.name),346severity: Severity.Error347};348}349350return undefined;351}352});353354if (!result) {355return undefined; // canceled356}357358let resolvedUri = result;359if (isSingleWord) {360resolvedUri = `https://${resolvedUri}.ghe.com`;361} else {362const normalizedUri = result.toLowerCase();363const hasHttps = normalizedUri.startsWith('https://');364if (!hasHttps) {365resolvedUri = `https://${result}`;366}367}368369await this.configurationService.updateValue(defaultChat.providerUriSetting, resolvedUri, ConfigurationTarget.USER);370371return true;372}373}374375376