Path: blob/main/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupController.ts
4780 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 } 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 { AuthenticationSession, IAuthenticationService } from '../../../../services/authentication/common/authentication.js';26import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js';27import { IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js';28import { ChatEntitlement, ChatEntitlementContext, ChatEntitlementRequests, isProUser } from '../../../../services/chat/common/chatEntitlementService.js';29import { CHAT_OPEN_ACTION_ID } from '../actions/chatActions.js';30import { ChatViewId, ChatViewContainerId } from '../chat.js';31import { ChatSetupAnonymous, ChatSetupStep, ChatSetupResultValue, InstallChatEvent, InstallChatClassification, refreshTokens } from './chatSetup.js';3233const defaultChat = {34chatExtensionId: product.defaultChatAgent?.chatExtensionId ?? '',35provider: product.defaultChatAgent?.provider ?? { default: { id: '', name: '' }, enterprise: { id: '', name: '' }, apple: { id: '', name: '' }, google: { id: '', name: '' } },36providerUriSetting: product.defaultChatAgent?.providerUriSetting ?? '',37completionsAdvancedSetting: product.defaultChatAgent?.completionsAdvancedSetting ?? '',38};3940export interface IChatSetupControllerOptions {41readonly forceSignIn?: boolean;42readonly useSocialProvider?: string;43readonly useEnterpriseProvider?: boolean;44readonly additionalScopes?: readonly string[];45readonly forceAnonymous?: ChatSetupAnonymous;46}4748export class ChatSetupController extends Disposable {4950private readonly _onDidChange = this._register(new Emitter<void>());51readonly onDidChange = this._onDidChange.event;5253private _step = ChatSetupStep.Initial;54get step(): ChatSetupStep { return this._step; }5556constructor(57private readonly context: ChatEntitlementContext,58private readonly requests: ChatEntitlementRequests,59@ITelemetryService private readonly telemetryService: ITelemetryService,60@IAuthenticationService private readonly authenticationService: IAuthenticationService,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) {72super();7374this.registerListeners();75}7677private registerListeners(): void {78this._register(this.context.onDidChange(() => this._onDidChange.fire()));79}8081private setStep(step: ChatSetupStep): void {82if (this._step === step) {83return;84}8586this._step = step;87this._onDidChange.fire();88}8990async setup(options: IChatSetupControllerOptions = {}): Promise<ChatSetupResultValue> {91const watch = new StopWatch(false);92const title = localize('setupChatProgress', "Getting chat ready...");93const badge = this.activityService.showViewContainerActivity(ChatViewContainerId, {94badge: new ProgressBadge(() => title),95});9697try {98return await this.progressService.withProgress({99location: ProgressLocation.Window,100command: CHAT_OPEN_ACTION_ID,101title,102}, () => this.doSetup(options, watch));103} finally {104badge.dispose();105}106}107108private async doSetup(options: IChatSetupControllerOptions, watch: StopWatch): Promise<ChatSetupResultValue> {109this.context.suspend(); // reduces flicker110111let success: ChatSetupResultValue = false;112try {113const providerId = ChatEntitlementRequests.providerId(this.configurationService);114let session: AuthenticationSession | undefined;115let entitlement: ChatEntitlement | undefined;116117let signIn: boolean;118if (options.forceSignIn) {119signIn = true; // forced to sign in120} else if (this.context.state.entitlement === ChatEntitlement.Unknown) {121if (options.forceAnonymous) {122signIn = false; // forced to anonymous without sign in123} else {124signIn = true; // sign in since we are signed out125}126} else {127signIn = false; // already signed in128}129130if (signIn) {131this.setStep(ChatSetupStep.SigningIn);132const result = await this.signIn(options);133if (!result.session) {134this.doInstall(); // still install the extension in the background to remind the user to sign-in eventually135136const provider = options.useSocialProvider ?? (options.useEnterpriseProvider ? defaultChat.provider.enterprise.id : defaultChat.provider.default.id);137this.telemetryService.publicLog2<InstallChatEvent, InstallChatClassification>('commandCenter.chatInstall', { installResult: 'failedNotSignedIn', installDuration: watch.elapsed(), signUpErrorCode: undefined, provider });138return undefined; // treat as cancelled because signing in already triggers an error dialog139}140141session = result.session;142entitlement = result.entitlement;143}144145// Await Install146this.setStep(ChatSetupStep.Installing);147success = await this.install(session, entitlement ?? this.context.state.entitlement, providerId, watch, options);148} finally {149this.setStep(ChatSetupStep.Initial);150this.context.resume();151}152153return success;154}155156private async signIn(options: IChatSetupControllerOptions): Promise<{ session: AuthenticationSession | undefined; entitlement: ChatEntitlement | undefined }> {157let session: AuthenticationSession | undefined;158let entitlements;159try {160({ session, entitlements } = await this.requests.signIn(options));161} catch (e) {162this.logService.error(`[chat setup] signIn: error ${e}`);163}164165if (!session && !this.lifecycleService.willShutdown) {166const { confirmed } = await this.dialogService.confirm({167type: Severity.Error,168message: localize('unknownSignInError', "Failed to sign in to {0}. Would you like to try again?", ChatEntitlementRequests.providerId(this.configurationService) === defaultChat.provider.enterprise.id ? defaultChat.provider.enterprise.name : defaultChat.provider.default.name),169detail: localize('unknownSignInErrorDetail', "You must be signed in to use AI features."),170primaryButton: localize('retry', "Retry")171});172173if (confirmed) {174return this.signIn(options);175}176}177178return { session, entitlement: entitlements?.entitlement };179}180181private async install(session: AuthenticationSession | undefined, entitlement: ChatEntitlement, providerId: string, watch: StopWatch, options: IChatSetupControllerOptions): Promise<ChatSetupResultValue> {182const wasRunning = this.context.state.installed && !this.context.state.disabled;183let signUpResult: boolean | { errorCode: number } | undefined = undefined;184185let provider: string;186if (options.forceAnonymous && entitlement === ChatEntitlement.Unknown) {187provider = 'anonymous';188} else {189provider = options.useSocialProvider ?? (options.useEnterpriseProvider ? defaultChat.provider.enterprise.id : defaultChat.provider.default.id);190}191192let sessions = session ? [session] : undefined;193try {194if (195!options.forceAnonymous && // User is not asking for anonymous access196entitlement !== ChatEntitlement.Free && // User is not signed up to Copilot Free197!isProUser(entitlement) && // User is not signed up for a Copilot subscription198entitlement !== ChatEntitlement.Unavailable // User is eligible for Copilot Free199) {200if (!sessions) {201try {202// Consider all sessions for the provider to be suitable for signing up203const existingSessions = await this.authenticationService.getSessions(providerId);204sessions = existingSessions.length > 0 ? [...existingSessions] : undefined;205} catch (error) {206// ignore - errors can throw if a provider is not registered207}208209if (!sessions || sessions.length === 0) {210this.telemetryService.publicLog2<InstallChatEvent, InstallChatClassification>('commandCenter.chatInstall', { installResult: 'failedNoSession', installDuration: watch.elapsed(), signUpErrorCode: undefined, provider });211return false; // unexpected212}213}214215signUpResult = await this.requests.signUpFree(sessions);216217if (typeof signUpResult !== 'boolean' /* error */) {218this.telemetryService.publicLog2<InstallChatEvent, InstallChatClassification>('commandCenter.chatInstall', { installResult: 'failedSignUp', installDuration: watch.elapsed(), signUpErrorCode: signUpResult.errorCode, provider });219}220}221222await this.doInstallWithRetry();223} catch (error) {224this.logService.error(`[chat setup] install: error ${error}`);225this.telemetryService.publicLog2<InstallChatEvent, InstallChatClassification>('commandCenter.chatInstall', { installResult: isCancellationError(error) ? 'cancelled' : 'failedInstall', installDuration: watch.elapsed(), signUpErrorCode: undefined, provider });226return false;227}228229if (typeof signUpResult === 'boolean' /* not an error case */ || typeof signUpResult === 'undefined' /* already signed up */) {230this.telemetryService.publicLog2<InstallChatEvent, InstallChatClassification>('commandCenter.chatInstall', { installResult: wasRunning && !signUpResult ? 'alreadyInstalled' : 'installed', installDuration: watch.elapsed(), signUpErrorCode: undefined, provider });231}232233if (wasRunning) {234// We always trigger refresh of tokens to help the user235// get out of authentication issues that can happen when236// for example the sign-up ran after the extension tried237// to use the authentication information to mint a token238refreshTokens(this.commandService);239}240241return true;242}243244private async doInstallWithRetry(): Promise<void> {245let error: Error | undefined;246try {247await this.doInstall();248} catch (e) {249this.logService.error(`[chat setup] install: error ${error}`);250error = e;251}252253if (error) {254if (!this.lifecycleService.willShutdown) {255const { confirmed } = await this.dialogService.confirm({256type: Severity.Error,257message: localize('unknownSetupError', "An error occurred while setting up chat. Would you like to try again?"),258detail: error && !isCancellationError(error) ? toErrorMessage(error) : undefined,259primaryButton: localize('retry', "Retry")260});261262if (confirmed) {263return this.doInstallWithRetry();264}265}266267throw error;268}269}270271private async doInstall(): Promise<void> {272await this.extensionsWorkbenchService.install(defaultChat.chatExtensionId, {273enable: true,274isApplicationScoped: true, // install into all profiles275isMachineScoped: false, // do not ask to sync276installEverywhere: true, // install in local and remote277installPreReleaseVersion: this.productService.quality !== 'stable'278}, ChatViewId);279}280281async setupWithProvider(options: IChatSetupControllerOptions): Promise<ChatSetupResultValue> {282const registry = Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration);283registry.registerConfiguration({284'id': 'copilot.setup',285'type': 'object',286'properties': {287[defaultChat.completionsAdvancedSetting]: {288'type': 'object',289'properties': {290'authProvider': {291'type': 'string'292}293}294},295[defaultChat.providerUriSetting]: {296'type': 'string'297}298}299});300301if (options.useEnterpriseProvider) {302const success = await this.handleEnterpriseInstance();303if (!success) {304this.telemetryService.publicLog2<InstallChatEvent, InstallChatClassification>('commandCenter.chatInstall', { installResult: 'failedEnterpriseSetup', installDuration: 0, signUpErrorCode: undefined, provider: undefined });305return success; // not properly configured, abort306}307}308309let existingAdvancedSetting = this.configurationService.inspect(defaultChat.completionsAdvancedSetting).user?.value;310if (!isObject(existingAdvancedSetting)) {311existingAdvancedSetting = {};312}313314if (options.useEnterpriseProvider) {315await this.configurationService.updateValue(`${defaultChat.completionsAdvancedSetting}`, {316...existingAdvancedSetting,317'authProvider': defaultChat.provider.enterprise.id318}, ConfigurationTarget.USER);319} else {320await this.configurationService.updateValue(`${defaultChat.completionsAdvancedSetting}`, Object.keys(existingAdvancedSetting).length > 0 ? {321...existingAdvancedSetting,322'authProvider': undefined323} : undefined, ConfigurationTarget.USER);324}325326return this.setup({ ...options, forceSignIn: true });327}328329private async handleEnterpriseInstance(): Promise<ChatSetupResultValue> {330const domainRegEx = /^[a-zA-Z\-_]+$/;331const fullUriRegEx = /^(https:\/\/)?([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.ghe\.com\/?$/;332333const uri = this.configurationService.getValue<string>(defaultChat.providerUriSetting);334if (typeof uri === 'string' && fullUriRegEx.test(uri)) {335return true; // already setup with a valid URI336}337338let isSingleWord = false;339const result = await this.quickInputService.input({340prompt: localize('enterpriseInstance', "What is your {0} instance?", defaultChat.provider.enterprise.name),341placeHolder: localize('enterpriseInstancePlaceholder', 'i.e. "octocat" or "https://octocat.ghe.com"...'),342ignoreFocusLost: true,343value: uri,344validateInput: async value => {345isSingleWord = false;346if (!value) {347return undefined;348}349350if (domainRegEx.test(value)) {351isSingleWord = true;352return {353content: localize('willResolveTo', "Will resolve to {0}", `https://${value}.ghe.com`),354severity: Severity.Info355};356} if (!fullUriRegEx.test(value)) {357return {358content: localize('invalidEnterpriseInstance', 'You must enter a valid {0} instance (i.e. "octocat" or "https://octocat.ghe.com")', defaultChat.provider.enterprise.name),359severity: Severity.Error360};361}362363return undefined;364}365});366367if (!result) {368return undefined; // canceled369}370371let resolvedUri = result;372if (isSingleWord) {373resolvedUri = `https://${resolvedUri}.ghe.com`;374} else {375const normalizedUri = result.toLowerCase();376const hasHttps = normalizedUri.startsWith('https://');377if (!hasHttps) {378resolvedUri = `https://${result}`;379}380}381382await this.configurationService.updateValue(defaultChat.providerUriSetting, resolvedUri, ConfigurationTarget.USER);383384return true;385}386}387388389